Compare commits
143 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
c71985f621 | ||
|
c8fe0ba8b6 | ||
|
d6df28c503 | ||
|
75ace25c57 | ||
|
d2d7dc2c8f | ||
|
f622b0c23e | ||
|
f312714a2c | ||
|
c3f26ca225 | ||
|
f6c7307647 | ||
|
b1714b9674 | ||
|
fa030e3060 | ||
|
564db06179 | ||
|
3cd75807f9 | ||
|
f6f9d01060 | ||
|
c6e30b67ad | ||
|
e4be239bf0 | ||
|
1e004774c9 | ||
|
91ca1e2190 | ||
|
d1662b47d9 | ||
|
dfe0fceea9 | ||
|
268014cc3a | ||
|
d1b1d7c59c | ||
|
470fc1078f | ||
|
96f4d9193a | ||
|
7aa42603bd | ||
|
9a6392d1e6 | ||
|
739b84e9f4 | ||
|
66746b4eaa | ||
|
164450a888 | ||
|
191ea7ef3a | ||
|
ca8ddd38a2 | ||
|
a45e183914 | ||
|
506c458544 | ||
|
5e3987dc04 | ||
|
5b8bc1d707 | ||
|
b3e4060661 | ||
|
2185e9fcf7 | ||
|
5d35319164 | ||
|
c6d24fd970 | ||
|
17f39c557b | ||
|
f997bc9c9a | ||
|
930a97a8fa | ||
|
026fff3d7a | ||
|
f9eec130dd | ||
|
d75689ea97 | ||
|
6ea5635d28 | ||
|
4b9221128c | ||
|
b5349315be | ||
|
ee18f157f1 | ||
|
ae325736d5 | ||
|
826da72d4b | ||
|
b5231cd383 | ||
|
5ac843e48c | ||
|
74417319be | ||
|
f8555a6ed5 | ||
|
4346c27adc | ||
|
16adf57dae | ||
|
8534088f4a | ||
|
a81ed537a6 | ||
|
ed5bc0b2b9 | ||
|
8cb8904e58 | ||
|
aa093b8cc2 | ||
|
42b4d0317d | ||
|
a0e463bc58 | ||
|
4a2da61b51 | ||
|
5c481ccafe | ||
|
30760b879e | ||
|
af7868f62a | ||
|
62c44730d8 | ||
|
045d8a3e52 | ||
|
55bee210c6 | ||
|
d3746c8e98 | ||
|
ffcf07414f | ||
|
ffaace2eaa | ||
|
32455dd859 | ||
|
3e54340694 | ||
|
bc6c7d1c36 | ||
|
ebd746b7b2 | ||
|
ebc3ef9f1b | ||
|
7238605441 | ||
|
ea86737970 | ||
|
ab4c586c06 | ||
|
7977bde410 | ||
|
626472dd04 | ||
|
d9efe2da3c | ||
|
ec197f0829 | ||
|
335479bcfb | ||
|
cbf01c6d16 | ||
|
98ca118a60 | ||
|
c7aa0e28ee | ||
|
528c2e387c | ||
|
bc54d6b686 | ||
|
8f298de58e | ||
|
366859ebe0 | ||
|
8c51cc047e | ||
|
5081a31f1a | ||
|
aff69d95c1 | ||
|
0b8427c864 | ||
|
43dd6a34d9 | ||
|
2f4adfc183 | ||
|
0d015ad72e | ||
|
406ca0485f | ||
|
ab6760226e | ||
|
66c8626d43 | ||
|
5fa724ae69 | ||
|
d417a94829 | ||
|
9c85a1075e | ||
|
dd4b502861 | ||
|
0f963b20f9 | ||
|
f63b6627b2 | ||
|
00db5d5e0f | ||
|
5e53d40e68 | ||
|
6ffa6934ed | ||
|
34dcf9f1b4 | ||
|
7931f92335 | ||
|
42b655862c | ||
|
e6eb056ec6 | ||
|
7f5b6fc349 | ||
|
9af175b11f | ||
|
c783e44444 | ||
|
a1dba753e8 | ||
|
a55a0e7205 | ||
|
bb8de60efe | ||
|
0d8c2b6648 | ||
|
64a96d816d | ||
|
5a4af54118 | ||
|
4cf18e008d | ||
|
50cc52b116 | ||
|
35f38b9f68 | ||
|
c883ba0175 | ||
|
39cf03a70e | ||
|
44d88d99bb | ||
|
ca67071e91 | ||
|
b5acce6449 | ||
|
7bdf01a67e | ||
|
aca08827fb | ||
|
d4aaa4dc74 | ||
|
98daa40bfd | ||
|
a7df896468 | ||
|
fd149dcafa | ||
|
0bb2e9329f | ||
|
89a32bfeda | ||
|
50089cb57a |
@@ -1,525 +0,0 @@
|
||||
#! /usr/bin/python
|
||||
# vim:ts=4:sw=4:softtabstop=4:smarttab:expandtab
|
||||
|
||||
|
||||
# this program works in concert with the output from KindleUnpack
|
||||
|
||||
'''
|
||||
Convert from Mobi ML to XHTML
|
||||
'''
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
|
||||
SPECIAL_HANDLING_TAGS = {
|
||||
'?xml' : ('xmlheader', -1),
|
||||
'!--' : ('comment', -3),
|
||||
'!DOCTYPE' : ('doctype', -1),
|
||||
}
|
||||
|
||||
SPECIAL_HANDLING_TYPES = ['xmlheader', 'doctype', 'comment']
|
||||
|
||||
SELF_CLOSING_TAGS = ['br' , 'hr', 'input', 'img', 'image', 'meta', 'spacer', 'link', 'frame', 'base', 'col', 'reference']
|
||||
|
||||
class MobiMLConverter(object):
|
||||
|
||||
PAGE_BREAK_PAT = re.compile(r'(<[/]{0,1}mbp:pagebreak\s*[/]{0,1}>)+', re.IGNORECASE)
|
||||
IMAGE_ATTRS = ('lowrecindex', 'recindex', 'hirecindex')
|
||||
|
||||
def __init__(self, filename):
|
||||
self.base_css_rules = 'blockquote { margin: 0em 0em 0em 1.25em }\n'
|
||||
self.base_css_rules += 'p { margin: 0em }\n'
|
||||
self.base_css_rules += '.bold { font-weight: bold }\n'
|
||||
self.base_css_rules += '.italic { font-style: italic }\n'
|
||||
self.base_css_rules += '.mbp_pagebreak { page-break-after: always; margin: 0; display: block }\n'
|
||||
self.tag_css_rules = {}
|
||||
self.tag_css_rule_cnt = 0
|
||||
self.path = []
|
||||
self.filename = filename
|
||||
self.wipml = open(self.filename, 'rb').read()
|
||||
self.pos = 0
|
||||
self.opfname = self.filename.rsplit('.',1)[0] + '.opf'
|
||||
self.opos = 0
|
||||
self.meta = ''
|
||||
self.cssname = os.path.join(os.path.dirname(self.filename),'styles.css')
|
||||
self.current_font_size = 3
|
||||
self.font_history = []
|
||||
|
||||
def cleanup_html(self):
|
||||
self.wipml = re.sub(r'<div height="0(pt|px|ex|em|%){0,1}"></div>', '', self.wipml)
|
||||
self.wipml = self.wipml.replace('\r\n', '\n')
|
||||
self.wipml = self.wipml.replace('> <', '>\n<')
|
||||
self.wipml = self.wipml.replace('<mbp: ', '<mbp:')
|
||||
# self.wipml = re.sub(r'<?xml[^>]*>', '', self.wipml)
|
||||
self.wipml = self.wipml.replace('<br></br>','<br/>')
|
||||
|
||||
def replace_page_breaks(self):
|
||||
self.wipml = self.PAGE_BREAK_PAT.sub(
|
||||
'<div class="mbp_pagebreak" />',
|
||||
self.wipml)
|
||||
|
||||
# parse leading text of ml and tag
|
||||
def parseml(self):
|
||||
p = self.pos
|
||||
if p >= len(self.wipml):
|
||||
return None
|
||||
if self.wipml[p] != '<':
|
||||
res = self.wipml.find('<',p)
|
||||
if res == -1 :
|
||||
res = len(self.wipml)
|
||||
self.pos = res
|
||||
return self.wipml[p:res], None
|
||||
# handle comment as a special case to deal with multi-line comments
|
||||
if self.wipml[p:p+4] == '<!--':
|
||||
te = self.wipml.find('-->',p+1)
|
||||
if te != -1:
|
||||
te = te+2
|
||||
else :
|
||||
te = self.wipml.find('>',p+1)
|
||||
ntb = self.wipml.find('<',p+1)
|
||||
if ntb != -1 and ntb < te:
|
||||
self.pos = ntb
|
||||
return self.wipml[p:ntb], None
|
||||
self.pos = te + 1
|
||||
return None, self.wipml[p:te+1]
|
||||
|
||||
# parses string version of tag to identify its name,
|
||||
# its type 'begin', 'end' or 'single',
|
||||
# plus build a hashtable of its attributes
|
||||
# code is written to handle the possiblity of very poor formating
|
||||
def parsetag(self, s):
|
||||
p = 1
|
||||
# get the tag name
|
||||
tname = None
|
||||
ttype = None
|
||||
tattr = {}
|
||||
while s[p:p+1] == ' ' :
|
||||
p += 1
|
||||
if s[p:p+1] == '/':
|
||||
ttype = 'end'
|
||||
p += 1
|
||||
while s[p:p+1] == ' ' :
|
||||
p += 1
|
||||
b = p
|
||||
while s[p:p+1] not in ('>', '/', ' ', '"', "'", "\r", "\n") :
|
||||
p += 1
|
||||
tname=s[b:p].lower()
|
||||
if tname == '!doctype':
|
||||
tname = '!DOCTYPE'
|
||||
# special cases
|
||||
if tname in SPECIAL_HANDLING_TAGS.keys():
|
||||
ttype, backstep = SPECIAL_HANDLING_TAGS[tname]
|
||||
tattr['special'] = s[p:backstep]
|
||||
if ttype is None:
|
||||
# parse any attributes
|
||||
while s.find('=',p) != -1 :
|
||||
while s[p:p+1] == ' ' :
|
||||
p += 1
|
||||
b = p
|
||||
while s[p:p+1] != '=' :
|
||||
p += 1
|
||||
aname = s[b:p].lower()
|
||||
aname = aname.rstrip(' ')
|
||||
p += 1
|
||||
while s[p:p+1] == ' ' :
|
||||
p += 1
|
||||
if s[p:p+1] in ('"', "'") :
|
||||
p = p + 1
|
||||
b = p
|
||||
while s[p:p+1] not in ('"', "'") :
|
||||
p += 1
|
||||
val = s[b:p]
|
||||
p += 1
|
||||
else :
|
||||
b = p
|
||||
while s[p:p+1] not in ('>', '/', ' ') :
|
||||
p += 1
|
||||
val = s[b:p]
|
||||
tattr[aname] = val
|
||||
# label beginning and single tags
|
||||
if ttype is None:
|
||||
ttype = 'begin'
|
||||
if s.find(' /',p) >= 0:
|
||||
ttype = 'single_ext'
|
||||
elif s.find('/',p) >= 0:
|
||||
ttype = 'single'
|
||||
return ttype, tname, tattr
|
||||
|
||||
# main routine to convert from mobi markup language to html
|
||||
def processml(self):
|
||||
|
||||
# are these really needed
|
||||
html_done = False
|
||||
head_done = False
|
||||
body_done = False
|
||||
|
||||
skip = False
|
||||
|
||||
htmlstr = ''
|
||||
self.replace_page_breaks()
|
||||
self.cleanup_html()
|
||||
|
||||
# now parse the cleaned up ml into standard xhtml
|
||||
while True:
|
||||
|
||||
r = self.parseml()
|
||||
if not r:
|
||||
break
|
||||
|
||||
text, tag = r
|
||||
|
||||
if text:
|
||||
if not skip:
|
||||
htmlstr += text
|
||||
|
||||
if tag:
|
||||
ttype, tname, tattr = self.parsetag(tag)
|
||||
|
||||
# If we run into a DTD or xml declarations inside the body ... bail.
|
||||
if tname in SPECIAL_HANDLING_TAGS.keys() and tname != 'comment' and body_done:
|
||||
htmlstr += '\n</body></html>'
|
||||
break
|
||||
|
||||
# make sure self-closing tags actually self-close
|
||||
if ttype == 'begin' and tname in SELF_CLOSING_TAGS:
|
||||
ttype = 'single'
|
||||
|
||||
# make sure any end tags of self-closing tags are discarded
|
||||
if ttype == 'end' and tname in SELF_CLOSING_TAGS:
|
||||
continue
|
||||
|
||||
# remove embedded guide and refernces from old mobis
|
||||
if tname in ('guide', 'ncx', 'reference') and ttype in ('begin', 'single', 'single_ext'):
|
||||
tname = 'removeme:{0}'.format(tname)
|
||||
tattr = None
|
||||
if tname in ('guide', 'ncx', 'reference', 'font', 'span') and ttype == 'end':
|
||||
if self.path[-1] == 'removeme:{0}'.format(tname):
|
||||
tname = 'removeme:{0}'.format(tname)
|
||||
tattr = None
|
||||
|
||||
# Get rid of font tags that only have a color attribute.
|
||||
if tname == 'font' and ttype in ('begin', 'single', 'single_ext'):
|
||||
if 'color' in tattr.keys() and len(tattr.keys()) == 1:
|
||||
tname = 'removeme:{0}'.format(tname)
|
||||
tattr = None
|
||||
|
||||
# Get rid of empty spans in the markup.
|
||||
if tname == 'span' and ttype in ('begin', 'single', 'single_ext') and not len(tattr):
|
||||
tname = 'removeme:{0}'.format(tname)
|
||||
|
||||
# need to handle fonts outside of the normal methods
|
||||
# so fonts tags won't be added to the self.path since we keep track
|
||||
# of font tags separately with self.font_history
|
||||
if tname == 'font' and ttype == 'begin':
|
||||
# check for nested font start tags
|
||||
if len(self.font_history) > 0 :
|
||||
# inject a font end tag
|
||||
taginfo = ('end', 'font', None)
|
||||
htmlstr += self.processtag(taginfo)
|
||||
self.font_history.append((ttype, tname, tattr))
|
||||
# handle the current font start tag
|
||||
taginfo = (ttype, tname, tattr)
|
||||
htmlstr += self.processtag(taginfo)
|
||||
continue
|
||||
|
||||
# check for nested font tags and unnest them
|
||||
if tname == 'font' and ttype == 'end':
|
||||
self.font_history.pop()
|
||||
# handle this font end tag
|
||||
taginfo = ('end', 'font', None)
|
||||
htmlstr += self.processtag(taginfo)
|
||||
# check if we were nested
|
||||
if len(self.font_history) > 0:
|
||||
# inject a copy of the most recent font start tag from history
|
||||
taginfo = self.font_history[-1]
|
||||
htmlstr += self.processtag(taginfo)
|
||||
continue
|
||||
|
||||
# keep track of nesting path
|
||||
if ttype == 'begin':
|
||||
self.path.append(tname)
|
||||
elif ttype == 'end':
|
||||
if tname != self.path[-1]:
|
||||
print ('improper nesting: ', self.path, tname, ttype)
|
||||
if tname not in self.path:
|
||||
# handle case of end tag with no beginning by injecting empty begin tag
|
||||
taginfo = ('begin', tname, None)
|
||||
htmlstr += self.processtag(taginfo)
|
||||
print(" - fixed by injecting empty start tag ", tname)
|
||||
self.path.append(tname)
|
||||
elif len(self.path) > 1 and tname == self.path[-2]:
|
||||
# handle case of dangling missing end
|
||||
taginfo = ('end', self.path[-1], None)
|
||||
htmlstr += self.processtag(taginfo)
|
||||
print(" - fixed by injecting end tag ", self.path[-1])
|
||||
self.path.pop()
|
||||
self.path.pop()
|
||||
|
||||
if tname == 'removeme:{0}'.format(tname):
|
||||
if ttype in ('begin', 'single', 'single_ext'):
|
||||
skip = True
|
||||
else:
|
||||
skip = False
|
||||
else:
|
||||
taginfo = (ttype, tname, tattr)
|
||||
htmlstr += self.processtag(taginfo)
|
||||
|
||||
# handle potential issue of multiple html, head, and body sections
|
||||
if tname == 'html' and ttype == 'begin' and not html_done:
|
||||
htmlstr += '\n'
|
||||
html_done = True
|
||||
|
||||
if tname == 'head' and ttype == 'begin' and not head_done:
|
||||
htmlstr += '\n'
|
||||
# also add in metadata and style link tags
|
||||
htmlstr += self.meta
|
||||
htmlstr += '<link href="styles.css" rel="stylesheet" type="text/css" />\n'
|
||||
head_done = True
|
||||
|
||||
if tname == 'body' and ttype == 'begin' and not body_done:
|
||||
htmlstr += '\n'
|
||||
body_done = True
|
||||
|
||||
# handle issue of possibly missing html, head, and body tags
|
||||
# I have not seen this but the original did something like this so ...
|
||||
if not body_done:
|
||||
htmlstr = '<body>\n' + htmlstr + '</body>\n'
|
||||
if not head_done:
|
||||
headstr = '<head>\n'
|
||||
headstr += self.meta
|
||||
headstr += '<link href="styles.css" rel="stylesheet" type="text/css" />\n'
|
||||
headstr += '</head>\n'
|
||||
htmlstr = headstr + htmlstr
|
||||
if not html_done:
|
||||
htmlstr = '<html>\n' + htmlstr + '</html>\n'
|
||||
|
||||
# finally add DOCTYPE info
|
||||
htmlstr = '<?xml version="1.0"?>\n<!DOCTYPE HTML PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">\n' + htmlstr
|
||||
|
||||
css = self.base_css_rules
|
||||
for cls, rule in self.tag_css_rules.items():
|
||||
css += '.%s { %s }\n' % (cls, rule)
|
||||
|
||||
return (htmlstr, css, self.cssname)
|
||||
|
||||
def ensure_unit(self, raw, unit='px'):
|
||||
if re.search(r'\d+$', raw) is not None:
|
||||
raw += unit
|
||||
return raw
|
||||
|
||||
# flatten possibly modified tag back to string
|
||||
def taginfo_tostring(self, taginfo):
|
||||
(ttype, tname, tattr) = taginfo
|
||||
if ttype is None or tname is None:
|
||||
return ''
|
||||
if ttype == 'end':
|
||||
return '</%s>' % tname
|
||||
if ttype in SPECIAL_HANDLING_TYPES and tattr is not None and 'special' in tattr.keys():
|
||||
info = tattr['special']
|
||||
if ttype == 'comment':
|
||||
return '<%s %s-->' % tname, info
|
||||
else:
|
||||
return '<%s %s>' % tname, info
|
||||
res = []
|
||||
res.append('<%s' % tname)
|
||||
if tattr is not None:
|
||||
for key in tattr.keys():
|
||||
res.append(' %s="%s"' % (key, tattr[key]))
|
||||
if ttype == 'single':
|
||||
res.append('/>')
|
||||
elif ttype == 'single_ext':
|
||||
res.append(' />')
|
||||
else :
|
||||
res.append('>')
|
||||
return "".join(res)
|
||||
|
||||
# routines to convert from mobi ml tags atributes to xhtml attributes and styles
|
||||
def processtag(self, taginfo):
|
||||
# Converting mobi font sizes to numerics
|
||||
size_map = {
|
||||
'xx-small': '1',
|
||||
'x-small': '2',
|
||||
'small': '3',
|
||||
'medium': '4',
|
||||
'large': '5',
|
||||
'x-large': '6',
|
||||
'xx-large': '7',
|
||||
}
|
||||
|
||||
size_to_em_map = {
|
||||
'1': '.65em',
|
||||
'2': '.75em',
|
||||
'3': '1em',
|
||||
'4': '1.125em',
|
||||
'5': '1.25em',
|
||||
'6': '1.5em',
|
||||
'7': '2em',
|
||||
}
|
||||
|
||||
# current tag to work on
|
||||
(ttype, tname, tattr) = taginfo
|
||||
if not tattr:
|
||||
tattr = {}
|
||||
|
||||
styles = []
|
||||
|
||||
if tname is None or tname.startswith('removeme'):
|
||||
return ''
|
||||
|
||||
# have not seen an example of this yet so keep it here to be safe
|
||||
# until this is better understood
|
||||
if tname in ('country-region', 'place', 'placetype', 'placename',
|
||||
'state', 'city', 'street', 'address', 'content'):
|
||||
tname = 'div' if tname == 'content' else 'span'
|
||||
for key in tattr.keys():
|
||||
tattr.pop(key)
|
||||
|
||||
# handle general case of style, height, width, bgcolor in any tag
|
||||
if 'style' in tattr.keys():
|
||||
style = tattr.pop('style').strip()
|
||||
if style:
|
||||
styles.append(style)
|
||||
|
||||
if 'align' in tattr.keys():
|
||||
align = tattr.pop('align').strip()
|
||||
if align:
|
||||
if tname in ('table', 'td', 'tr'):
|
||||
pass
|
||||
else:
|
||||
styles.append('text-align: %s' % align)
|
||||
|
||||
if 'height' in tattr.keys():
|
||||
height = tattr.pop('height').strip()
|
||||
if height and '<' not in height and '>' not in height and re.search(r'\d+', height):
|
||||
if tname in ('table', 'td', 'tr'):
|
||||
pass
|
||||
elif tname == 'img':
|
||||
tattr['height'] = height
|
||||
else:
|
||||
styles.append('margin-top: %s' % self.ensure_unit(height))
|
||||
|
||||
if 'width' in tattr.keys():
|
||||
width = tattr.pop('width').strip()
|
||||
if width and re.search(r'\d+', width):
|
||||
if tname in ('table', 'td', 'tr'):
|
||||
pass
|
||||
elif tname == 'img':
|
||||
tattr['width'] = width
|
||||
else:
|
||||
styles.append('text-indent: %s' % self.ensure_unit(width))
|
||||
if width.startswith('-'):
|
||||
styles.append('margin-left: %s' % self.ensure_unit(width[1:]))
|
||||
|
||||
if 'bgcolor' in tattr.keys():
|
||||
# no proprietary html allowed
|
||||
if tname == 'div':
|
||||
del tattr['bgcolor']
|
||||
|
||||
elif tname == 'font':
|
||||
# Change font tags to span tags
|
||||
tname = 'span'
|
||||
if ttype in ('begin', 'single', 'single_ext'):
|
||||
# move the face attribute to css font-family
|
||||
if 'face' in tattr.keys():
|
||||
face = tattr.pop('face').strip()
|
||||
styles.append('font-family: "%s"' % face)
|
||||
|
||||
# Monitor the constantly changing font sizes, change them to ems and move
|
||||
# them to css. The following will work for 'flat' font tags, but nested font tags
|
||||
# will cause things to go wonky. Need to revert to the parent font tag's size
|
||||
# when a closing tag is encountered.
|
||||
if 'size' in tattr.keys():
|
||||
sz = tattr.pop('size').strip().lower()
|
||||
try:
|
||||
float(sz)
|
||||
except ValueError:
|
||||
if sz in size_map.keys():
|
||||
sz = size_map[sz]
|
||||
else:
|
||||
if sz.startswith('-') or sz.startswith('+'):
|
||||
sz = self.current_font_size + float(sz)
|
||||
if sz > 7:
|
||||
sz = 7
|
||||
elif sz < 1:
|
||||
sz = 1
|
||||
sz = str(int(sz))
|
||||
styles.append('font-size: %s' % size_to_em_map[sz])
|
||||
self.current_font_size = int(sz)
|
||||
|
||||
elif tname == 'img':
|
||||
for attr in ('width', 'height'):
|
||||
if attr in tattr:
|
||||
val = tattr[attr]
|
||||
if val.lower().endswith('em'):
|
||||
try:
|
||||
nval = float(val[:-2])
|
||||
nval *= 16 * (168.451/72) # Assume this was set using the Kindle profile
|
||||
tattr[attr] = "%dpx"%int(nval)
|
||||
except:
|
||||
del tattr[attr]
|
||||
elif val.lower().endswith('%'):
|
||||
del tattr[attr]
|
||||
|
||||
# convert the anchor tags
|
||||
if 'filepos-id' in tattr:
|
||||
tattr['id'] = tattr.pop('filepos-id')
|
||||
if 'name' in tattr and tattr['name'] != tattr['id']:
|
||||
tattr['name'] = tattr['id']
|
||||
|
||||
if 'filepos' in tattr:
|
||||
filepos = tattr.pop('filepos')
|
||||
try:
|
||||
tattr['href'] = "#filepos%d" % int(filepos)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if styles:
|
||||
ncls = None
|
||||
rule = '; '.join(styles)
|
||||
for sel, srule in self.tag_css_rules.items():
|
||||
if srule == rule:
|
||||
ncls = sel
|
||||
break
|
||||
if ncls is None:
|
||||
self.tag_css_rule_cnt += 1
|
||||
ncls = 'rule_%d' % self.tag_css_rule_cnt
|
||||
self.tag_css_rules[ncls] = rule
|
||||
cls = tattr.get('class', '')
|
||||
cls = cls + (' ' if cls else '') + ncls
|
||||
tattr['class'] = cls
|
||||
|
||||
# convert updated tag back to string representation
|
||||
if len(tattr) == 0:
|
||||
tattr = None
|
||||
taginfo = (ttype, tname, tattr)
|
||||
return self.taginfo_tostring(taginfo)
|
||||
|
||||
''' main only left in for testing outside of plugin '''
|
||||
|
||||
def main(argv=sys.argv):
|
||||
if len(argv) != 2:
|
||||
return 1
|
||||
else:
|
||||
infile = argv[1]
|
||||
|
||||
try:
|
||||
print('Converting Mobi Markup Language to XHTML')
|
||||
mlc = MobiMLConverter(infile)
|
||||
print('Processing ...')
|
||||
htmlstr, css, cssname = mlc.processml()
|
||||
outname = infile.rsplit('.',1)[0] + '_converted.html'
|
||||
file(outname, 'wb').write(htmlstr)
|
||||
file(cssname, 'wb').write(css)
|
||||
print('Completed')
|
||||
print('XHTML version of book can be found at: ', outname)
|
||||
|
||||
except ValueError as e:
|
||||
print("Error: %s" % e)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
37
Lector.pro
Normal file
@@ -0,0 +1,37 @@
|
||||
# This file is a part of Lector, a Qt based ebook reader
|
||||
# Copyright (C) 2017-2019 BasioMeusPuga
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
SOURCES += lector/__main__.py \
|
||||
lector/annotations.py \
|
||||
lector/contentwidgets.py \
|
||||
lector/definitionsdialog.py \
|
||||
lector/dockwidgets.py \
|
||||
lector/library.py \
|
||||
lector/metadatadialog.py \
|
||||
lector/models.py \
|
||||
lector/settingsdialog.py \
|
||||
lector/toolbars.py \
|
||||
lector/widgets.py \
|
||||
lector/resources/definitions.py \
|
||||
lector/resources/settingswindow.py \
|
||||
lector/resources/metadata.py \
|
||||
lector/resources/mainwindow.py
|
||||
|
||||
TRANSLATIONS += lector/resources/translations/Lector_es.ts \
|
||||
lector/resources/translations/Lector_fr.ts \
|
||||
lector/resources/translations/Lector_de.ts \
|
||||
lector/resources/translations/Lector_zh.ts \
|
||||
lector/resources/translations/SAMPLE.ts
|
71
README.md
@@ -1,27 +1,42 @@
|
||||
# Lector
|
||||
<p align="center"><img src="lector/resources/raw/logo/logotype_horizontal.png" alt="Lector" height="90px"></p>
|
||||
|
||||
Qt based ebook reader
|
||||
|
||||
Currently supports:
|
||||
* pdf
|
||||
* epub
|
||||
* fb2
|
||||
* mobi
|
||||
* azw / azw3 / azw4
|
||||
* cbr / cbz
|
||||
|
||||
Support for a bunch of other formats is coming. Please see the TODO for additional information.
|
||||
## Contribute
|
||||
[Paypal](https://www.paypal.me/supportlector)
|
||||
|
||||
Bitcoin: 17jaxj26vFJNqQ2hEVerbBV5fpTusfqFro
|
||||
|
||||
## Requirements
|
||||
### Needed
|
||||
| Package | Version tested |
|
||||
| --- | --- |
|
||||
| Qt5 | 5.10.1 |
|
||||
| Python | 3.6 |
|
||||
| PyQt5 | 5.10.1 |
|
||||
| python-requests | 2.18.4 |
|
||||
| python-lxml | 4.3.0 |
|
||||
| python-beautifulsoup4 | 4.6.0 |
|
||||
| poppler-qt5 | 0.61.1 |
|
||||
| python-poppler-qt5 | 0.24.2 |
|
||||
| python-xmltodict | 0.11.0 |
|
||||
|
||||
poppler-qt5 and python-poppler-qt5 are optional.
|
||||
### Optional
|
||||
| Package | Version tested |
|
||||
| --- | --- |
|
||||
| python-pymupdf | 1.14.5 |
|
||||
|
||||
## Support
|
||||
When reporting issues:
|
||||
* Make sure you're at the latest commit.
|
||||
* Run with `$EXECUTABLEPATH debug`.
|
||||
* Include the log `~/.local/share/Lector/Lector.log` AND terminal output.
|
||||
* If you're having trouble with a book while the rest of the application / other books work, please link to a copy of the book itself.
|
||||
* If nothing is working, please make sure the requirements mentioned above are all installed, and are at least at the version mentioned.
|
||||
|
||||
## Installation
|
||||
### Manual
|
||||
@@ -34,39 +49,55 @@ poppler-qt5 and python-poppler-qt5 are optional.
|
||||
3. OR launch with `lector/__main__.py`
|
||||
|
||||
### Available packages
|
||||
* [AUR](https://aur.archlinux.org/packages/lector-git/)
|
||||
* [AUR - Releases](https://aur.archlinux.org/packages/lector/)
|
||||
* [AUR - Git](https://aur.archlinux.org/packages/lector-git/)
|
||||
* [Gentoo (unofficial)](https://bitbucket.org/szymonsz/gen2-overlay/src/master/app-text/lector/)
|
||||
* [Fedora (unofficial)](https://copr.fedorainfracloud.org/coprs/bugzy/lector/)
|
||||
* [openSUSE](https://software.opensuse.org/package/lector)
|
||||
|
||||
## Reporting issues
|
||||
When reporting issues:
|
||||
## Translations
|
||||
1. There is a `SAMPLE.ts` file [here](https://github.com/BasioMeusPuga/Lector/tree/master/lector/resources/translations). Open it in `Qt Linguist`.
|
||||
2. Pick the language you wish to translate to.
|
||||
3. Translate relevant strings.
|
||||
4. Try to resist the urge to include profanity.
|
||||
5. Save the file as `Lector_<language>` and send it to me, preferably as a pull request.
|
||||
|
||||
* If you're having trouble with a book while the rest of the application / other books work, please link to a copy of the book itself.
|
||||
* If nothing is working, please make sure the requirements mentioned above are all installed, and are at least at the version mentioned.
|
||||
Please keep the translations short. There's only so much space for UI elements.
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Main window
|
||||

|
||||

|
||||
|
||||
### Table view
|
||||

|
||||

|
||||
|
||||
### Book reading view
|
||||

|
||||

|
||||
|
||||
### Distraction free view
|
||||

|
||||
|
||||
### Annotation support
|
||||

|
||||
|
||||
### Comic reading view
|
||||

|
||||

|
||||
|
||||
### Bookmark support
|
||||

|
||||

|
||||
|
||||
### View profiles
|
||||

|
||||

|
||||
|
||||
### Metadata editor
|
||||

|
||||

|
||||
|
||||
### In program dictionary
|
||||

|
||||

|
||||
|
||||
### Settings window
|
||||

|
||||
|
||||
## Attributions
|
||||
* [KindleUnpack](https://github.com/kevinhendricks/KindleUnpack)
|
||||
|
77
TODO
@@ -1,4 +1,10 @@
|
||||
TODO
|
||||
General:
|
||||
✓ Internationalization
|
||||
✓ Application icon
|
||||
✓ .desktop file
|
||||
✓ Shift to logging instead of print statements
|
||||
Flatpak and AppImage support
|
||||
Options:
|
||||
✓ Automatic library management
|
||||
✓ Recursive file addition
|
||||
@@ -25,9 +31,14 @@ TODO
|
||||
✓ Information dialog widget
|
||||
✓ Allow editing of database data through the UI + for Bookmarks
|
||||
✓ Include (action) icons with the applications
|
||||
✓ Drag and drop support for the library
|
||||
✓ Tab reordering
|
||||
Additional Settings:
|
||||
✓ Create covers for books without them - VERY SLOW
|
||||
Set focus to newly added file
|
||||
Reading:
|
||||
✓ Drop down for TOC
|
||||
✓ Treeview navigation for TOC
|
||||
✓ Override the keypress event of the textedit
|
||||
✓ Use format* icons for toolbar buttons
|
||||
✓ Implement book view settings with a(nother) toolbar
|
||||
@@ -51,48 +62,76 @@ TODO
|
||||
✓ Cache next and previous images
|
||||
✓ Set context menu for definitions and the like
|
||||
✓ Paragraph indentation
|
||||
Search document using QTextCursor?
|
||||
Comic view keyboard shortcuts
|
||||
✓ Comic view keyboard shortcuts
|
||||
✓ Comic view context menu
|
||||
✓ Make the bookmark dock float over the reading area
|
||||
✓ Spacebar should not cut off lines at the top
|
||||
✓ Track open bookmark windows so they can be closed quickly at exit
|
||||
✓ Search document using QTextCursor
|
||||
✓ Double page / column view
|
||||
✓ For comics
|
||||
Caching is currently non functional
|
||||
Annotations
|
||||
✓ Text
|
||||
✓ Disable buttons for annotations, search in images
|
||||
Adjust key navigation according to viewport dimensions
|
||||
Filetypes:
|
||||
✓ pdf support
|
||||
Parse TOC
|
||||
✓ Parse TOC
|
||||
✓ epub support
|
||||
✓ Homegrown solution please
|
||||
✓ cbz, cbr support
|
||||
✓ Keep font settings enabled but only for background color
|
||||
✓ Double page view
|
||||
✓ Manga mode
|
||||
✓ mobi, azw support
|
||||
Limit the extra files produced by KindleUnpack
|
||||
Have them save to memory
|
||||
✓ fb2 support
|
||||
✓ Images need to show up in their placeholders
|
||||
Other:
|
||||
✓ Define every widget in code
|
||||
Bugs:
|
||||
If there are files open and the database is deleted, TypeErrors result
|
||||
Cover culling does not occur if some other tab has initial focus
|
||||
Slider position change might be acting up too
|
||||
Take metadata from the database when opening the file
|
||||
Deselecting all directories in the settings dialog also filters out manually added books
|
||||
Last line in QTextBrowser should never be cut off
|
||||
Bookmark name for a page that's not on the TOC and has nothing before
|
||||
Screen position still keeps jumping when inside a paragraph
|
||||
Better recursion needed for fb2 toc
|
||||
Search results should ignore punctuation
|
||||
Keep text size for annotations
|
||||
Sort by new is not working
|
||||
|
||||
Secondary:
|
||||
Annotations
|
||||
Text to speech
|
||||
Definitions dialog needs to respond to escape
|
||||
Zoom slider for comics
|
||||
Tab tooltip
|
||||
Additional Settings:
|
||||
Find definitions on Google
|
||||
Disable progressbar - 20% book addition speed improvement
|
||||
Disable cover loading when reading - Saves ~2M / book
|
||||
Special formatting for each chapter's title
|
||||
Signal end of chapter with some text
|
||||
Graphical themes
|
||||
Change focus rectangle dimensions
|
||||
Tab reordering
|
||||
Universal Ctrl + Tab
|
||||
Allow tabs to detach and form their own windows
|
||||
Goodreads API: Ratings, Read, Recommendations
|
||||
Get ISBN using python-isbnlib
|
||||
Pagination
|
||||
Use embedded fonts + CSS
|
||||
Scrolling: Smooth / By Line
|
||||
Spacebar should not cut off lines at the top
|
||||
Shift to logging instead of print statements
|
||||
txt, doc, chm, djvu, fb2 support
|
||||
txt, doc, chm, djvu support
|
||||
Include icons for filetype emblems
|
||||
Drag and drop support for the library
|
||||
Comic view modes
|
||||
Continuous paging
|
||||
Double pages
|
||||
Leave comic images on disk in case tab isn't closed and files are remembered
|
||||
Give the comic view a 'Save image as...' option
|
||||
Ignore a / the / numbers for sorting purposes
|
||||
? Add only one file type if multiple are present
|
||||
? Plugin system for parsers
|
||||
? Create emblem per filetype
|
||||
In application notifications
|
||||
Notification in case the filter is filtering out all files with no option in place
|
||||
Option to fit images to viewport
|
||||
|
||||
Need help with:
|
||||
Double page view for books
|
||||
Scrolling: Smooth / By Line
|
||||
Annotation preview in listView
|
||||
Pagination
|
||||
|
143
com.basiomeuspuga.Lector.json
Normal file
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"app-id":"com.basiomeuspuga.Lector",
|
||||
"runtime":"org.kde.Platform",
|
||||
"runtime-version":"5.12",
|
||||
"sdk":"org.kde.Sdk",
|
||||
"command":"lector",
|
||||
"rename-icon":"Lector",
|
||||
"rename-desktop-file":"lector.desktop",
|
||||
"rename-appdata-file":"lector.appdata.xml",
|
||||
"finish-args":[
|
||||
"--filesystem=host",
|
||||
"--socket=x11",
|
||||
"--socket=wayland",
|
||||
"--device=dri",
|
||||
"--share=ipc",
|
||||
"--share=network"
|
||||
],
|
||||
"build-options":{
|
||||
"cflags":"-O2",
|
||||
"cxxflags":"-O2"
|
||||
},
|
||||
"modules":[
|
||||
{
|
||||
"name": "PyQt5",
|
||||
"buildsystem": "simple",
|
||||
"build-commands": [
|
||||
"pip3 install --prefix=/app PyQt5-5.12-5.12.1-cp35.cp36.cp37.cp38-abi3-manylinux1_x86_64.whl"
|
||||
],
|
||||
"modules":[
|
||||
{
|
||||
"name":"PyQt5-sip",
|
||||
"sources":[
|
||||
{
|
||||
"type":"file",
|
||||
"url":"https://files.pythonhosted.org/packages/ae/9c/74fba0b62a0756d214f9aded5b0184130f7866def7532fa68823f34feefa/PyQt5_sip-4.19.14-cp37-cp37m-manylinux1_x86_64.whl",
|
||||
"sha256":"04bd0bb8b6f8fa03c2dfbdfff0c8c9bfb3f46a21dd4cac73983dae93bf949523"
|
||||
}
|
||||
],
|
||||
"buildsystem":"simple",
|
||||
"build-commands":[
|
||||
"pip3 install --prefix=/app PyQt5_sip-4.19.14-cp37-cp37m-manylinux1_x86_64.whl"
|
||||
]
|
||||
}
|
||||
],
|
||||
"sources": [
|
||||
{
|
||||
"type": "file",
|
||||
"url": "https://files.pythonhosted.org/packages/5e/91/9ac8827d0af428e756f461a3aa7bcbc53d6450edfe026e27569f5ff3689e/PyQt5-5.12-5.12.1-cp35.cp36.cp37.cp38-abi3-manylinux1_x86_64.whl",
|
||||
"sha256": "fd5946795b39922f971cf92dec799aadc7544b7fa993a79b9f6059f13d817e6e"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"beautifulsoup4",
|
||||
"buildsystem":"simple",
|
||||
"sources":[
|
||||
{
|
||||
"type":"file",
|
||||
"url":"https://files.pythonhosted.org/packages/1d/5d/3260694a59df0ec52f8b4883f5d23b130bc237602a1411fa670eae12351e/beautifulsoup4-4.7.1-py3-none-any.whl",
|
||||
"sha256":"034740f6cb549b4e932ae1ab975581e6103ac8f942200a0e9759065984391858"
|
||||
}
|
||||
],
|
||||
"modules":[
|
||||
{
|
||||
"name": "soupsieve",
|
||||
"sources":[
|
||||
{
|
||||
"type":"file",
|
||||
"url":"https://files.pythonhosted.org/packages/bf/b3/2473abf05c4950c6a829ed5dcbc40d8b56d4351d15d6939c8ffb7c6b1a14/soupsieve-1.7.3-py2.py3-none-any.whl",
|
||||
"sha256":"466910df7561796a60748826781ebe9a888f7a1668a636ae86783f44d10aae73"
|
||||
}
|
||||
],
|
||||
"buildsystem":"simple",
|
||||
"build-commands":[
|
||||
"pip3 install --prefix=/app soupsieve-1.7.3-py2.py3-none-any.whl"
|
||||
]
|
||||
}
|
||||
],
|
||||
"build-commands":[
|
||||
"pip3 install --prefix=/app beautifulsoup4-4.7.1-py3-none-any.whl"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"xmltodict",
|
||||
"buildsystem":"simple",
|
||||
"sources":[
|
||||
{
|
||||
"type": "file",
|
||||
"url":"https://files.pythonhosted.org/packages/28/fd/30d5c1d3ac29ce229f6bdc40bbc20b28f716e8b363140c26eff19122d8a5/xmltodict-0.12.0-py2.py3-none-any.whl",
|
||||
"sha256":"8bbcb45cc982f48b2ca8fe7e7827c5d792f217ecf1792626f808bf41c3b86051"
|
||||
}
|
||||
],
|
||||
"build-commands":[
|
||||
"pip3 install --prefix=/app xmltodict-0.12.0-py2.py3-none-any.whl"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"PyMuPDF",
|
||||
"buildsystem":"simple",
|
||||
"build-commands": [
|
||||
"pip3 install --prefix=/app PyMuPDF-1.14.8-cp37-cp37m-manylinux1_x86_64.whl"
|
||||
],
|
||||
"sources":[
|
||||
{
|
||||
"type": "file",
|
||||
"url": "https://files.pythonhosted.org/packages/3c/df/4bfaee2631b505d502c2ba64aa437799f0a64125edb1d4c4c38044ad1ecc/PyMuPDF-1.14.8-cp37-cp37m-manylinux1_x86_64.whl",
|
||||
"sha256": "a49798b58cce00e09b8a4431a5f64a400b11a0959f29507187c471208ce040a5"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"lxml",
|
||||
"buildsystem":"simple",
|
||||
"sources":[
|
||||
{
|
||||
"type":"file",
|
||||
"url":"https://files.pythonhosted.org/packages/08/f2/04bf04e42c070f65b64dbde02d2c94851251f19f5e9f803cc8f8bc61ac77/lxml-4.3.1-cp37-cp37m-manylinux1_x86_64.whl",
|
||||
"sha256":"c0a7751ba1a4bfbe7831920d98cee3ce748007eab8dfda74593d44079568219a"
|
||||
}
|
||||
],
|
||||
"build-commands":[
|
||||
"pip3 install --prefix=/app lxml-4.3.1-cp37-cp37m-manylinux1_x86_64.whl"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"lector",
|
||||
"buildsystem":"simple",
|
||||
"ensure-writable":[
|
||||
"/lib/python*/site-packages/easy-install.pth"
|
||||
],
|
||||
"sources":[
|
||||
{
|
||||
"type":"git",
|
||||
"url":"https://github.com/BasioMeusPuga/Lector.git"
|
||||
}
|
||||
],
|
||||
"build-commands":[
|
||||
"python3 setup.py build",
|
||||
"python3 setup.py install --prefix=/app"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@@ -1,317 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# This file is a part of Lector, a Qt based ebook reader
|
||||
# Copyright (C) 2017 BasioMeusPuga
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import zipfile
|
||||
from urllib.parse import unquote
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
class EPUB:
|
||||
def __init__(self, filename):
|
||||
self.filename = filename
|
||||
self.zip_file = None
|
||||
self.book = {}
|
||||
self.book['split_chapters'] = {}
|
||||
|
||||
def read_epub(self):
|
||||
# This is the function that should error out in
|
||||
# case the module cannot process the file
|
||||
self.load_zip()
|
||||
contents_path = self.get_file_path(
|
||||
None, True)
|
||||
|
||||
if not contents_path:
|
||||
return False # No opf was found so processing cannot continue
|
||||
|
||||
self.generate_book_metadata(contents_path)
|
||||
self.parse_toc()
|
||||
|
||||
return True
|
||||
|
||||
def load_zip(self):
|
||||
try:
|
||||
self.zip_file = zipfile.ZipFile(
|
||||
self.filename, mode='r', allowZip64=True)
|
||||
except (KeyError, AttributeError, zipfile.BadZipFile):
|
||||
print('Cannot parse ' + self.filename)
|
||||
return
|
||||
|
||||
def parse_xml(self, filename, parser):
|
||||
try:
|
||||
this_xml = self.zip_file.read(filename).decode()
|
||||
except KeyError:
|
||||
short_filename = os.path.basename(self.filename)
|
||||
print(f'{str(filename)} not found in {short_filename}')
|
||||
return
|
||||
|
||||
root = BeautifulSoup(this_xml, parser)
|
||||
return root
|
||||
|
||||
def get_file_path(self, filename, is_content_file=False):
|
||||
# Use this to get the location of the content.opf file
|
||||
# And maybe some other file that has a more well formatted
|
||||
|
||||
# We're going to all this trouble because there really is
|
||||
# no going forward without a toc
|
||||
if is_content_file:
|
||||
container_location = self.get_file_path('container.xml')
|
||||
xml = self.parse_xml(container_location, 'xml')
|
||||
|
||||
if xml:
|
||||
root_item = xml.find('rootfile')
|
||||
return root_item.get('full-path')
|
||||
else:
|
||||
possible_filenames = ('content.opf', 'package.opf')
|
||||
for i in possible_filenames:
|
||||
presumptive_location = self.get_file_path(i)
|
||||
if presumptive_location:
|
||||
return presumptive_location
|
||||
|
||||
for i in self.zip_file.filelist:
|
||||
if os.path.basename(i.filename) == os.path.basename(filename):
|
||||
return i.filename
|
||||
|
||||
return None
|
||||
|
||||
def read_from_zip(self, filename):
|
||||
filename = unquote(filename)
|
||||
try:
|
||||
file_data = self.zip_file.read(filename)
|
||||
return file_data
|
||||
except KeyError:
|
||||
file_path_actual = self.get_file_path(filename)
|
||||
if file_path_actual:
|
||||
return self.zip_file.read(file_path_actual)
|
||||
else:
|
||||
print('ePub module can\'t find ' + filename)
|
||||
|
||||
#______________________________________________________
|
||||
|
||||
def generate_book_metadata(self, contents_path):
|
||||
self.book['title'] = 'Unknown'
|
||||
self.book['author'] = 'Unknown'
|
||||
self.book['isbn'] = None
|
||||
self.book['tags'] = None
|
||||
self.book['cover'] = None
|
||||
self.book['toc_file'] = 'toc.ncx' # Overwritten if another one exists
|
||||
|
||||
# Parse XML
|
||||
xml = self.parse_xml(contents_path, 'xml')
|
||||
|
||||
# Parse metadata
|
||||
item_dict = {
|
||||
'title': 'title',
|
||||
'author': 'creator',
|
||||
'year': 'date'}
|
||||
|
||||
for i in item_dict.items():
|
||||
item = xml.find(i[1])
|
||||
if item:
|
||||
self.book[i[0]] = item.text
|
||||
|
||||
try:
|
||||
self.book['year'] = int(self.book['year'][:4])
|
||||
except (TypeError, KeyError, IndexError, ValueError):
|
||||
self.book['year'] = 9999
|
||||
|
||||
# Get identifier
|
||||
identifier_items = xml.find_all('identifier')
|
||||
for i in identifier_items:
|
||||
scheme = i.get('scheme')
|
||||
try:
|
||||
if scheme.lower() == 'isbn':
|
||||
self.book['isbn'] = i.text
|
||||
except AttributeError:
|
||||
self.book['isbn'] = None
|
||||
|
||||
# Tags
|
||||
tag_items = xml.find_all('subject')
|
||||
tag_list = [i.text for i in tag_items]
|
||||
self.book['tags'] = tag_list
|
||||
|
||||
# Get items
|
||||
self.book['content_dict'] = {}
|
||||
all_items = xml.find_all('item')
|
||||
for i in all_items:
|
||||
media_type = i.get('media-type')
|
||||
this_id = i.get('id')
|
||||
|
||||
if media_type == 'application/xhtml+xml' or media_type == 'text/html':
|
||||
self.book['content_dict'][this_id] = i.get('href')
|
||||
|
||||
if media_type == 'application/x-dtbncx+xml':
|
||||
self.book['toc_file'] = i.get('href')
|
||||
|
||||
# Cover image
|
||||
if 'cover' in this_id and media_type.split('/')[0] == 'image':
|
||||
cover_href = i.get('href')
|
||||
try:
|
||||
self.book['cover'] = self.zip_file.read(cover_href)
|
||||
except KeyError:
|
||||
# The cover cannot be found according to the
|
||||
# path specified in the content reference
|
||||
self.book['cover'] = self.zip_file.read(
|
||||
self.get_file_path(cover_href))
|
||||
|
||||
if not self.book['cover']:
|
||||
# If no cover is located the conventional way,
|
||||
# we go looking for the largest image in the book
|
||||
biggest_image_size = 0
|
||||
biggest_image = None
|
||||
for j in self.zip_file.filelist:
|
||||
if os.path.splitext(j.filename)[1] in ['.jpg', '.jpeg', '.png', '.gif']:
|
||||
if j.file_size > biggest_image_size:
|
||||
biggest_image = j.filename
|
||||
biggest_image_size = j.file_size
|
||||
|
||||
if biggest_image:
|
||||
self.book['cover'] = self.read_from_zip(biggest_image)
|
||||
else:
|
||||
print('No cover found for: ' + self.filename)
|
||||
|
||||
# Parse spine and arrange chapter paths acquired from the opf
|
||||
# according to the order IN THE SPINE
|
||||
spine_items = xml.find_all('itemref')
|
||||
spine_order = []
|
||||
for i in spine_items:
|
||||
spine_order.append(i.get('idref'))
|
||||
|
||||
self.book['chapters_in_order'] = []
|
||||
for i in spine_order:
|
||||
chapter_path = self.book['content_dict'][i]
|
||||
self.book['chapters_in_order'].append(chapter_path)
|
||||
|
||||
def parse_toc(self):
|
||||
# This has no bearing on the actual order
|
||||
# We're just using this to get chapter names
|
||||
self.book['navpoint_dict'] = {}
|
||||
|
||||
toc_file = self.book['toc_file']
|
||||
if toc_file:
|
||||
toc_file = self.get_file_path(toc_file)
|
||||
|
||||
xml = self.parse_xml(toc_file, 'xml')
|
||||
if not xml:
|
||||
return
|
||||
|
||||
navpoints = xml.find_all('navPoint')
|
||||
|
||||
for i in navpoints:
|
||||
chapter_title = i.find('text').text
|
||||
chapter_source = i.find('content').get('src')
|
||||
chapter_source_file = unquote(chapter_source.split('#')[0])
|
||||
|
||||
if '#' in chapter_source:
|
||||
try:
|
||||
self.book['split_chapters'][chapter_source_file].append(
|
||||
(chapter_source.split('#')[1], chapter_title))
|
||||
except KeyError:
|
||||
self.book['split_chapters'][chapter_source_file] = []
|
||||
self.book['split_chapters'][chapter_source_file].append(
|
||||
(chapter_source.split('#')[1], chapter_title))
|
||||
|
||||
self.book['navpoint_dict'][chapter_source_file] = chapter_title
|
||||
|
||||
def parse_chapters(self, temp_dir=None, split_large_xml=False):
|
||||
no_title_chapter = 0
|
||||
self.book['book_list'] = []
|
||||
|
||||
for i in self.book['chapters_in_order']:
|
||||
chapter_data = self.read_from_zip(i).decode()
|
||||
|
||||
if i in self.book['split_chapters'] and not split_large_xml:
|
||||
split_chapters = get_split_content(
|
||||
chapter_data, self.book['split_chapters'][i])
|
||||
self.book['book_list'].extend(split_chapters)
|
||||
|
||||
elif split_large_xml:
|
||||
# https://stackoverflow.com/questions/14444732/how-to-split-a-html-page-to-multiple-pages-using-python-and-beautiful-soup
|
||||
markup = BeautifulSoup(chapter_data, 'xml')
|
||||
chapters = []
|
||||
pagebreaks = markup.find_all('pagebreak')
|
||||
|
||||
def next_element(elem):
|
||||
while elem is not None:
|
||||
elem = elem.next_sibling
|
||||
if hasattr(elem, 'name'):
|
||||
return elem
|
||||
|
||||
for pbreak in pagebreaks:
|
||||
chapter = [str(pbreak)]
|
||||
elem = next_element(pbreak)
|
||||
while elem and elem.name != 'pagebreak':
|
||||
chapter.append(str(elem))
|
||||
elem = next_element(elem)
|
||||
chapters.append('\n'.join(chapter))
|
||||
|
||||
for this_chapter in chapters:
|
||||
fallback_title = str(no_title_chapter)
|
||||
self.book['book_list'].append(
|
||||
(fallback_title, this_chapter + ('<br/>' * 8)))
|
||||
no_title_chapter += 1
|
||||
else:
|
||||
try:
|
||||
self.book['book_list'].append(
|
||||
(self.book['navpoint_dict'][i], chapter_data + ('<br/>' * 8)))
|
||||
except KeyError:
|
||||
fallback_title = str(no_title_chapter)
|
||||
self.book['book_list'].append(
|
||||
(fallback_title, chapter_data))
|
||||
no_title_chapter += 1
|
||||
|
||||
cover_path = os.path.join(temp_dir, os.path.basename(self.filename)) + '- cover'
|
||||
if self.book['cover']:
|
||||
with open(cover_path, 'wb') as cover_temp:
|
||||
cover_temp.write(self.book['cover'])
|
||||
|
||||
self.book['book_list'][0] = (
|
||||
'Cover', f'<center><img src="{cover_path}" alt="Cover"></center>')
|
||||
|
||||
|
||||
def get_split_content(chapter_data, split_by):
|
||||
split_anchors = [i[0] for i in split_by]
|
||||
chapter_titles = [i[1] for i in split_by]
|
||||
return_list = []
|
||||
|
||||
xml = BeautifulSoup(chapter_data, 'lxml')
|
||||
xml_string = xml.body.prettify()
|
||||
|
||||
for count, i in enumerate(split_anchors):
|
||||
this_split = xml_string.split(i)
|
||||
current_chapter = this_split[0]
|
||||
|
||||
bs_obj = BeautifulSoup(current_chapter, 'lxml')
|
||||
# Since tags correspond to data following them, the first
|
||||
# chunk will be ignored
|
||||
# As will all empty chapters
|
||||
if bs_obj.text == '\n' or bs_obj.text == '' or count == 0:
|
||||
continue
|
||||
bs_obj_string = str(bs_obj).replace('">', '', 1) + ('<br/>' * 8)
|
||||
|
||||
return_list.append(
|
||||
(chapter_titles[count - 1], bs_obj_string))
|
||||
xml_string = this_split[1]
|
||||
|
||||
bs_obj = BeautifulSoup(xml_string, 'lxml')
|
||||
bs_obj_string = str(bs_obj).replace('">', '', 1) + ('<br/>' * 8)
|
||||
return_list.append(
|
||||
(chapter_titles[-1], bs_obj_string))
|
||||
|
||||
return return_list
|
@@ -6,7 +6,7 @@ from __future__ import unicode_literals, division, absolute_import, print_functi
|
||||
|
||||
import os
|
||||
|
||||
__path__ = ["lib", os.path.dirname(__file__), "kindleunpack"]
|
||||
__path__ = ["lib", os.path.dirname(os.path.realpath(__file__)), "kindleunpack"]
|
||||
|
||||
import sys
|
||||
import codecs
|
||||
@@ -140,6 +140,8 @@ if PY2:
|
||||
# 0.76 pre-release version only fix name related issues in opf by not using original file name in mobi7
|
||||
# 0.77 bug fix for unpacking HDImages with included Fonts
|
||||
# 0.80 converted to work with both python 2.7 and Python 3.3 and later
|
||||
# 0.81 various fixes
|
||||
# 0.82 Handle calibre-generated mobis that can have skeletons with no fragments
|
||||
|
||||
DUMP = False
|
||||
""" Set to True to dump all possible information. """
|
||||
@@ -847,7 +849,7 @@ def process_all_mobi_headers(files, apnxfile, sect, mhlst, K8Boundary, k8only=Fa
|
||||
return
|
||||
|
||||
|
||||
def unpackBook(infile, outdir, apnxfile=None, epubver='2', use_hd=True, dodump=False, dowriteraw=False, dosplitcombos=False):
|
||||
def unpackBook(infile, outdir, apnxfile=None, epubver='2', use_hd=False, dodump=False, dowriteraw=False, dosplitcombos=False):
|
||||
global DUMP
|
||||
global WRITE_RAW_DATA
|
||||
global SPLIT_COMBO_MOBIS
|
||||
@@ -949,7 +951,7 @@ def main(argv=unicode_argv()):
|
||||
global WRITE_RAW_DATA
|
||||
global SPLIT_COMBO_MOBIS
|
||||
|
||||
print("KindleUnpack v0.80")
|
||||
print("KindleUnpack v0.82")
|
||||
print(" Based on initial mobipocket version Copyright © 2009 Charles M. Hannum <root@ihack.net>")
|
||||
print(" Extensive Extensions and Improvements Copyright © 2009-2014 ")
|
||||
print(" by: P. Durrant, K. Hendricks, S. Siebert, fandrieu, DiapDealer, nickredding, tkeo.")
|
@@ -180,9 +180,11 @@ class K8Processor:
|
||||
fragptr = 0
|
||||
baseptr = 0
|
||||
cnt = 0
|
||||
filename = 'part%04d.xhtml' % cnt
|
||||
for [skelnum, skelname, fragcnt, skelpos, skellen] in self.skeltbl:
|
||||
baseptr = skelpos + skellen
|
||||
skeleton = text[skelpos: baseptr]
|
||||
aidtext = "0"
|
||||
for i in range(fragcnt):
|
||||
[insertpos, idtext, filenum, seqnum, startpos, length] = self.fragtbl[fragptr]
|
||||
aidtext = idtext[12:-2]
|
1237
lector/__main__.py
317
lector/annotations.py
Normal file
@@ -0,0 +1,317 @@
|
||||
# This file is a part of Lector, a Qt based ebook reader
|
||||
# Copyright (C) 2017-2019 BasioMeusPuga
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import logging
|
||||
|
||||
from PyQt5 import QtWidgets, QtCore, QtGui
|
||||
|
||||
from lector.resources import annotationswindow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnnotationsUI(QtWidgets.QDialog, annotationswindow.Ui_Dialog):
|
||||
def __init__(self, parent=None):
|
||||
super(AnnotationsUI, self).__init__()
|
||||
self.setupUi(self)
|
||||
|
||||
self.parent = parent
|
||||
self._translate = QtCore.QCoreApplication.translate
|
||||
|
||||
# Current annotation
|
||||
self.modelIndex = None # The index of the annotations list model in the parent dialog
|
||||
self.current_annotation = {}
|
||||
|
||||
# Populate annotation type
|
||||
textmarkup_string = self._translate('AnnotationsUI', 'Text markup')
|
||||
all_types = [textmarkup_string]
|
||||
for i in all_types:
|
||||
self.typeBox.addItem(i)
|
||||
|
||||
# Init defaults
|
||||
self.default_stylesheet = self.foregroundCheck.styleSheet()
|
||||
self.foregroundColor = QtGui.QColor.fromRgb(0, 0, 0)
|
||||
self.underlineColor = QtGui.QColor.fromRgb(255, 0, 0)
|
||||
self.highlightColor = QtGui.QColor.fromRgb(66, 209, 56)
|
||||
self.underline_styles = {
|
||||
'Solid': QtGui.QTextCharFormat.SingleUnderline,
|
||||
'Dashes': QtGui.QTextCharFormat.DashUnderline,
|
||||
'Dots': QtGui.QTextCharFormat.DotLine,
|
||||
'Wavy': QtGui.QTextCharFormat.WaveUnderline}
|
||||
|
||||
# Push buttons
|
||||
self.foregroundColorButton.clicked.connect(self.modify_annotation)
|
||||
self.highlightColorButton.clicked.connect(self.modify_annotation)
|
||||
self.underlineColorButton.clicked.connect(self.modify_annotation)
|
||||
|
||||
self.okButton.clicked.connect(self.ok_pressed)
|
||||
self.cancelButton.clicked.connect(self.hide)
|
||||
|
||||
# Underline combo box
|
||||
underline_items = ['Solid', 'Dashes', 'Dots', 'Wavy']
|
||||
self.underlineType.addItems(underline_items)
|
||||
self.underlineType.currentIndexChanged.connect(self.modify_annotation)
|
||||
|
||||
# Text markup related checkboxes
|
||||
self.foregroundCheck.clicked.connect(self.modify_annotation)
|
||||
self.highlightCheck.clicked.connect(self.modify_annotation)
|
||||
self.boldCheck.clicked.connect(self.modify_annotation)
|
||||
self.italicCheck.clicked.connect(self.modify_annotation)
|
||||
self.underlineCheck.clicked.connect(self.modify_annotation)
|
||||
|
||||
def show_dialog(self, mode, index=None):
|
||||
# TODO
|
||||
# Account for annotation type here
|
||||
# and point to a relevant set of widgets accordingly
|
||||
|
||||
if mode == 'edit' or mode == 'preview':
|
||||
self.modelIndex = index
|
||||
this_annotation = self.parent.annotationModel.data(
|
||||
index, QtCore.Qt.UserRole)
|
||||
|
||||
annotation_name = this_annotation['name']
|
||||
self.nameEdit.setText(annotation_name)
|
||||
|
||||
annotation_components = this_annotation['components']
|
||||
|
||||
if 'foregroundColor' in annotation_components:
|
||||
self.foregroundCheck.setChecked(True)
|
||||
self.foregroundColor = annotation_components['foregroundColor']
|
||||
self.set_button_background_color(
|
||||
self.foregroundColorButton, annotation_components['foregroundColor'])
|
||||
else:
|
||||
self.foregroundCheck.setChecked(False)
|
||||
|
||||
if 'highlightColor' in annotation_components:
|
||||
self.highlightCheck.setChecked(True)
|
||||
self.highlightColor = annotation_components['highlightColor']
|
||||
self.set_button_background_color(
|
||||
self.highlightColorButton, annotation_components['highlightColor'])
|
||||
else:
|
||||
self.highlightCheck.setChecked(False)
|
||||
|
||||
if 'bold' in annotation_components:
|
||||
self.boldCheck.setChecked(True)
|
||||
else:
|
||||
self.boldCheck.setChecked(False)
|
||||
|
||||
if 'italic' in annotation_components:
|
||||
self.italicCheck.setChecked(True)
|
||||
else:
|
||||
self.italicCheck.setChecked(False)
|
||||
|
||||
if 'underline' in annotation_components:
|
||||
self.underlineCheck.setChecked(True)
|
||||
underline_params = annotation_components['underline']
|
||||
self.underlineType.setCurrentText(underline_params[0])
|
||||
self.set_button_background_color(
|
||||
self.underlineColorButton, underline_params[1])
|
||||
else:
|
||||
self.underlineCheck.setChecked(False)
|
||||
|
||||
elif mode == 'add':
|
||||
new_annotation_string = self._translate('AnnotationsUI', 'New annotation')
|
||||
self.nameEdit.setText(new_annotation_string)
|
||||
|
||||
all_checkboxes = (
|
||||
self.foregroundCheck, self.highlightCheck,
|
||||
self.boldCheck, self.italicCheck, self.underlineCheck)
|
||||
for i in all_checkboxes:
|
||||
i.setChecked(False)
|
||||
|
||||
self.modelIndex = None
|
||||
self.set_button_background_color(
|
||||
self.foregroundColorButton, self.foregroundColor)
|
||||
self.set_button_background_color(
|
||||
self.highlightColorButton, self.highlightColor)
|
||||
self.set_button_background_color(
|
||||
self.underlineColorButton, self.underlineColor)
|
||||
|
||||
self.update_preview()
|
||||
if mode != 'preview':
|
||||
self.show()
|
||||
|
||||
def set_button_background_color(self, button, color):
|
||||
button.setStyleSheet(
|
||||
"QPushButton {{background-color: {0}}}".format(color.name()))
|
||||
|
||||
def update_preview(self):
|
||||
cursor = self.parent.previewView.textCursor()
|
||||
cursor.setPosition(0)
|
||||
cursor.movePosition(QtGui.QTextCursor.End, QtGui.QTextCursor.KeepAnchor)
|
||||
|
||||
# TODO
|
||||
# Other kinds of text markup
|
||||
previewCharFormat = QtGui.QTextCharFormat()
|
||||
|
||||
if self.foregroundCheck.isChecked():
|
||||
previewCharFormat.setForeground(self.foregroundColor)
|
||||
|
||||
highlight = QtCore.Qt.transparent
|
||||
if self.highlightCheck.isChecked():
|
||||
highlight = self.highlightColor
|
||||
previewCharFormat.setBackground(highlight)
|
||||
|
||||
font_weight = QtGui.QFont.Normal
|
||||
if self.boldCheck.isChecked():
|
||||
font_weight = QtGui.QFont.Bold
|
||||
previewCharFormat.setFontWeight(font_weight)
|
||||
|
||||
if self.italicCheck.isChecked():
|
||||
previewCharFormat.setFontItalic(True)
|
||||
|
||||
if self.underlineCheck.isChecked():
|
||||
previewCharFormat.setFontUnderline(True)
|
||||
previewCharFormat.setUnderlineColor(self.underlineColor)
|
||||
previewCharFormat.setUnderlineStyle(
|
||||
self.underline_styles[self.underlineType.currentText()])
|
||||
|
||||
previewCharFormat.setFontStyleStrategy(
|
||||
QtGui.QFont.PreferAntialias)
|
||||
|
||||
cursor.setCharFormat(previewCharFormat)
|
||||
cursor.clearSelection()
|
||||
self.parent.previewView.setTextCursor(cursor)
|
||||
|
||||
def modify_annotation(self):
|
||||
sender = self.sender()
|
||||
if isinstance(sender, QtWidgets.QCheckBox):
|
||||
if not sender.isChecked():
|
||||
self.update_preview()
|
||||
return
|
||||
|
||||
new_color = None
|
||||
|
||||
if sender == self.foregroundColorButton:
|
||||
new_color = self.get_color(self.foregroundColor)
|
||||
self.foregroundColor = new_color
|
||||
|
||||
if sender == self.highlightColorButton:
|
||||
new_color = self.get_color(self.highlightColor)
|
||||
self.highlightColor = new_color
|
||||
|
||||
if sender == self.underlineColorButton:
|
||||
new_color = self.get_color(self.underlineColor)
|
||||
self.underlineColor = new_color
|
||||
|
||||
if new_color:
|
||||
self.set_button_background_color(sender, new_color)
|
||||
self.update_preview()
|
||||
|
||||
def get_color(self, current_color):
|
||||
color_dialog = QtWidgets.QColorDialog()
|
||||
new_color = color_dialog.getColor(current_color)
|
||||
if new_color.isValid(): # Returned in case cancel is pressed
|
||||
return new_color
|
||||
else:
|
||||
return current_color
|
||||
|
||||
def ok_pressed(self):
|
||||
annotation_name = self.nameEdit.text()
|
||||
if annotation_name == '':
|
||||
self.nameEdit.setText('Why do you like bugs? WHY?')
|
||||
return
|
||||
|
||||
annotation_components = {}
|
||||
if self.foregroundCheck.isChecked():
|
||||
annotation_components['foregroundColor'] = self.foregroundColor
|
||||
if self.highlightCheck.isChecked():
|
||||
annotation_components['highlightColor'] = self.highlightColor
|
||||
if self.boldCheck.isChecked():
|
||||
annotation_components['bold'] = True
|
||||
if self.italicCheck.isChecked():
|
||||
annotation_components['italic'] = True
|
||||
if self.underlineCheck.isChecked():
|
||||
annotation_components['underline'] = (
|
||||
self.underlineType.currentText(), self.underlineColor)
|
||||
|
||||
self.current_annotation = {
|
||||
'name': annotation_name,
|
||||
'applicable_to': 'text',
|
||||
'type': 'text_markup',
|
||||
'components': annotation_components}
|
||||
|
||||
if self.modelIndex:
|
||||
self.parent.annotationModel.setData(
|
||||
self.modelIndex, annotation_name, QtCore.Qt.DisplayRole)
|
||||
self.parent.annotationModel.setData(
|
||||
self.modelIndex, self.current_annotation, QtCore.Qt.UserRole)
|
||||
else: # New annotation
|
||||
new_annotation_item = QtGui.QStandardItem()
|
||||
new_annotation_item.setText(annotation_name)
|
||||
new_annotation_item.setData(self.current_annotation, QtCore.Qt.UserRole)
|
||||
self.parent.annotationModel.appendRow(new_annotation_item)
|
||||
|
||||
self.hide()
|
||||
|
||||
|
||||
class AnnotationPlacement:
|
||||
def __init__(self):
|
||||
self.annotation_type = None
|
||||
self.annotation_components = None
|
||||
self.underline_styles = {
|
||||
'Solid': QtGui.QTextCharFormat.SingleUnderline,
|
||||
'Dashes': QtGui.QTextCharFormat.DashUnderline,
|
||||
'Dots': QtGui.QTextCharFormat.DotLine,
|
||||
'Wavy': QtGui.QTextCharFormat.WaveUnderline}
|
||||
|
||||
def set_current_annotation(self, annotation_type, annotation_components):
|
||||
# Components expected to be a dictionary
|
||||
self.annotation_type = annotation_type # This is currently unused
|
||||
self.annotation_components = annotation_components
|
||||
|
||||
def format_text(self, cursor, start_here, end_here):
|
||||
# This is applicable only to the PliantQTextBrowser
|
||||
# for the text_markup style of annotation
|
||||
|
||||
# The cursor is the textCursor of the QTextEdit
|
||||
# containing the text that has to be modified
|
||||
|
||||
if not self.annotation_components:
|
||||
return
|
||||
|
||||
cursor.setPosition(start_here)
|
||||
cursor.setPosition(end_here, QtGui.QTextCursor.KeepAnchor)
|
||||
|
||||
newCharFormat = QtGui.QTextCharFormat()
|
||||
|
||||
if 'foregroundColor' in self.annotation_components:
|
||||
newCharFormat.setForeground(
|
||||
self.annotation_components['foregroundColor'])
|
||||
|
||||
if 'highlightColor' in self.annotation_components:
|
||||
newCharFormat.setBackground(
|
||||
self.annotation_components['highlightColor'])
|
||||
|
||||
if 'bold' in self.annotation_components:
|
||||
newCharFormat.setFontWeight(QtGui.QFont.Bold)
|
||||
|
||||
if 'italic' in self.annotation_components:
|
||||
newCharFormat.setFontItalic(True)
|
||||
|
||||
if 'underline' in self.annotation_components:
|
||||
newCharFormat.setFontUnderline(True)
|
||||
newCharFormat.setUnderlineStyle(
|
||||
self.underline_styles[self.annotation_components['underline'][0]])
|
||||
newCharFormat.setUnderlineColor(
|
||||
self.annotation_components['underline'][1])
|
||||
|
||||
newCharFormat.setFontStyleStrategy(
|
||||
QtGui.QFont.PreferAntialias)
|
||||
|
||||
cursor.setCharFormat(newCharFormat)
|
||||
cursor.clearSelection()
|
||||
return cursor
|
919
lector/contentwidgets.py
Normal file
@@ -0,0 +1,919 @@
|
||||
# This file is a part of Lector, a Qt based ebook reader
|
||||
# Copyright (C) 2017-2019 BasioMeusPuga
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import zipfile
|
||||
import logging
|
||||
import webbrowser
|
||||
|
||||
try:
|
||||
import fitz
|
||||
from lector.parsers.pdf import render_pdf_page
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
from PyQt5 import QtWidgets, QtGui, QtCore
|
||||
|
||||
from lector.rarfile import rarfile
|
||||
from lector.threaded import BackGroundCacheRefill
|
||||
from lector.annotations import AnnotationPlacement
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PliantQGraphicsView(QtWidgets.QGraphicsView):
|
||||
def __init__(self, filepath, main_window, parent=None):
|
||||
super(PliantQGraphicsView, self).__init__(parent)
|
||||
self._translate = QtCore.QCoreApplication.translate
|
||||
self.parent = parent
|
||||
self.main_window = main_window
|
||||
|
||||
self.image_pixmap = None
|
||||
self.image_cache = [None for _ in range(4)]
|
||||
|
||||
self.thread = None
|
||||
|
||||
self.annotation_dict = self.parent.metadata['annotations']
|
||||
|
||||
self.filepath = filepath
|
||||
self.filetype = os.path.splitext(self.filepath)[1][1:]
|
||||
|
||||
if self.filetype == 'cbz':
|
||||
self.book = zipfile.ZipFile(self.filepath)
|
||||
|
||||
elif self.filetype == 'cbr':
|
||||
self.book = rarfile.RarFile(self.filepath)
|
||||
|
||||
elif self.filetype == 'pdf':
|
||||
self.book = fitz.open(self.filepath)
|
||||
|
||||
self.common_functions = PliantWidgetsCommonFunctions(
|
||||
self, self.main_window)
|
||||
|
||||
self.ignore_wheel_event = False
|
||||
self.ignore_wheel_event_number = 0
|
||||
self.setMouseTracking(True)
|
||||
self.setDragMode(QtWidgets.QGraphicsView.ScrollHandDrag)
|
||||
|
||||
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(
|
||||
self.generate_graphicsview_context_menu)
|
||||
|
||||
def loadImage(self, current_page):
|
||||
all_pages = self.parent.metadata['content']
|
||||
current_page_index = all_pages.index(current_page)
|
||||
|
||||
double_page_mode = False
|
||||
if (self.main_window.settings['double_page_mode']
|
||||
and (current_page_index not in (0, len(all_pages) - 1))):
|
||||
double_page_mode = True
|
||||
|
||||
def load_page(current_page):
|
||||
def page_loader(page):
|
||||
pixmap = QtGui.QPixmap()
|
||||
|
||||
if self.filetype in ('cbz', 'cbr'):
|
||||
page_data = self.book.read(page)
|
||||
pixmap.loadFromData(page_data)
|
||||
|
||||
elif self.filetype == 'pdf':
|
||||
page_data = self.book.loadPage(page)
|
||||
pixmap = render_pdf_page(page_data)
|
||||
|
||||
return pixmap
|
||||
|
||||
firstPixmap = page_loader(current_page)
|
||||
if not double_page_mode:
|
||||
return firstPixmap
|
||||
|
||||
next_page = all_pages[current_page_index + 1]
|
||||
secondPixmap = page_loader(next_page)
|
||||
|
||||
# Pixmap height should be the greater of the 2 images
|
||||
pixmap_height = firstPixmap.height()
|
||||
if secondPixmap.height() > pixmap_height:
|
||||
pixmap_height = secondPixmap.height()
|
||||
|
||||
bigPixmap = QtGui.QPixmap(
|
||||
firstPixmap.width() + secondPixmap.width() + 5,
|
||||
pixmap_height)
|
||||
bigPixmap.fill(QtCore.Qt.transparent)
|
||||
imagePainter = QtGui.QPainter(bigPixmap)
|
||||
|
||||
manga_mode = self.main_window.settings['manga_mode']
|
||||
if manga_mode:
|
||||
imagePainter.drawPixmap(0, 0, secondPixmap)
|
||||
imagePainter.drawPixmap(secondPixmap.width() + 4, 0, firstPixmap)
|
||||
else:
|
||||
imagePainter.drawPixmap(0, 0, firstPixmap)
|
||||
imagePainter.drawPixmap(firstPixmap.width() + 4, 0, secondPixmap)
|
||||
|
||||
imagePainter.end()
|
||||
return bigPixmap
|
||||
|
||||
def generate_image_cache(current_page):
|
||||
logger.info('(Re)building image cache')
|
||||
current_page_index = all_pages.index(current_page)
|
||||
|
||||
# Image caching for single and double page views
|
||||
page_indices = (-1, 0, 1, 2)
|
||||
|
||||
index_modifier = 0
|
||||
if double_page_mode:
|
||||
index_modifier = 1
|
||||
|
||||
for i in page_indices:
|
||||
try:
|
||||
this_page = all_pages[current_page_index + i + index_modifier]
|
||||
this_pixmap = load_page(this_page)
|
||||
self.image_cache[i + 1] = (this_page, this_pixmap)
|
||||
except IndexError:
|
||||
self.image_cache[i + 1] = None
|
||||
|
||||
def refill_cache(remove_value):
|
||||
# Do NOT put a parent in here or the mother of all
|
||||
# memory leaks will result
|
||||
self.thread = BackGroundCacheRefill(
|
||||
self.image_cache, remove_value,
|
||||
self.filetype, self.book, all_pages)
|
||||
self.thread.finished.connect(overwrite_cache)
|
||||
self.thread.start()
|
||||
|
||||
def overwrite_cache():
|
||||
self.image_cache = self.thread.image_cache
|
||||
|
||||
def check_cache(current_page):
|
||||
for i in self.image_cache:
|
||||
if i:
|
||||
if i[0] == current_page:
|
||||
return_pixmap = i[1]
|
||||
refill_cache(i)
|
||||
return return_pixmap
|
||||
|
||||
# No return happened so the image isn't in the cache
|
||||
generate_image_cache(current_page)
|
||||
|
||||
# TODO
|
||||
# Get caching working for double page view
|
||||
if not double_page_mode and self.main_window.settings['caching_enabled']:
|
||||
return_pixmap = None
|
||||
while not return_pixmap:
|
||||
return_pixmap = check_cache(current_page)
|
||||
else:
|
||||
return_pixmap = load_page(current_page)
|
||||
|
||||
if self.main_window.settings['invert_colors']:
|
||||
qImg = return_pixmap.toImage()
|
||||
qImg.invertPixels()
|
||||
if qImg: # Will return None if conversion doesn't work
|
||||
return_pixmap = QtGui.QPixmap().fromImage(qImg)
|
||||
else:
|
||||
logger.error('Color inversion failed: ' + current_page)
|
||||
|
||||
self.image_pixmap = return_pixmap
|
||||
self.resizeEvent()
|
||||
|
||||
def resizeEvent(self, *args):
|
||||
if not self.image_pixmap:
|
||||
return
|
||||
|
||||
zoom_mode = self.main_window.comic_profile['zoom_mode']
|
||||
padding = self.main_window.comic_profile['padding']
|
||||
|
||||
if zoom_mode == 'fitWidth':
|
||||
available_width = self.viewport().width()
|
||||
image_pixmap = self.image_pixmap.scaledToWidth(
|
||||
available_width, QtCore.Qt.SmoothTransformation)
|
||||
|
||||
elif zoom_mode == 'originalSize':
|
||||
image_pixmap = self.image_pixmap
|
||||
|
||||
new_padding = (self.viewport().width() - image_pixmap.width()) // 2
|
||||
if new_padding < 0: # The image is larger than the viewport
|
||||
self.main_window.comic_profile['padding'] = 0
|
||||
else:
|
||||
self.main_window.comic_profile['padding'] = new_padding
|
||||
|
||||
elif zoom_mode == 'bestFit':
|
||||
available_width = self.viewport().width()
|
||||
available_height = self.viewport().height()
|
||||
|
||||
image_pixmap = self.image_pixmap.scaled(
|
||||
available_width, available_height,
|
||||
QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
|
||||
|
||||
self.main_window.comic_profile['padding'] = (
|
||||
self.viewport().width() - image_pixmap.width()) // 2
|
||||
|
||||
elif zoom_mode == 'manualZoom':
|
||||
available_width = self.viewport().width() - 2 * padding
|
||||
image_pixmap = self.image_pixmap.scaledToWidth(
|
||||
available_width, QtCore.Qt.SmoothTransformation)
|
||||
|
||||
graphicsScene = QtWidgets.QGraphicsScene()
|
||||
graphicsScene.addPixmap(image_pixmap)
|
||||
|
||||
self.setScene(graphicsScene)
|
||||
self.show()
|
||||
|
||||
# This prevents a partial page scroll on first load
|
||||
self.verticalScrollBar().setValue(0)
|
||||
|
||||
def wheelEvent(self, event):
|
||||
self.common_functions.wheelEvent(event)
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
vertical = self.verticalScrollBar().value()
|
||||
maximum = self.verticalScrollBar().maximum()
|
||||
|
||||
def scroller(increment, move_forward=True):
|
||||
if move_forward:
|
||||
if vertical == maximum:
|
||||
self.common_functions.change_chapter(1, True)
|
||||
else:
|
||||
next_val = vertical + increment
|
||||
if next_val >= .95 * maximum:
|
||||
next_val = maximum
|
||||
self.verticalScrollBar().setValue(next_val)
|
||||
else:
|
||||
if vertical == 0:
|
||||
self.common_functions.change_chapter(-1, False)
|
||||
else:
|
||||
next_val = vertical - increment
|
||||
if next_val <= .05 * maximum:
|
||||
next_val = 0
|
||||
self.verticalScrollBar().setValue(next_val)
|
||||
|
||||
small_increment = maximum //self.main_window.settings['small_increment']
|
||||
big_increment = maximum // self.main_window.settings['large_increment']
|
||||
|
||||
# Scrolling
|
||||
if event.key() == QtCore.Qt.Key_Up:
|
||||
scroller(small_increment, False)
|
||||
if event.key() == QtCore.Qt.Key_Down:
|
||||
scroller(small_increment)
|
||||
if event.key() == QtCore.Qt.Key_Space:
|
||||
scroller(big_increment)
|
||||
|
||||
# Double page mode and manga mode
|
||||
if event.key() in (QtCore.Qt.Key_D, QtCore.Qt.Key_M):
|
||||
self.main_window.change_page_view(event.key())
|
||||
|
||||
# Image fit modes
|
||||
view_modification_keys = (
|
||||
QtCore.Qt.Key_Plus, QtCore.Qt.Key_Minus, QtCore.Qt.Key_Equal,
|
||||
QtCore.Qt.Key_B, QtCore.Qt.Key_W, QtCore.Qt.Key_O)
|
||||
if event.key() in view_modification_keys:
|
||||
self.main_window.modify_comic_view(event.key())
|
||||
|
||||
def record_position(self):
|
||||
self.parent.metadata['position']['is_read'] = False
|
||||
self.common_functions.update_model()
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if QtWidgets.QApplication.mouseButtons() == QtCore.Qt.NoButton:
|
||||
self.viewport().setCursor(QtCore.Qt.OpenHandCursor)
|
||||
else:
|
||||
self.viewport().setCursor(QtCore.Qt.ClosedHandCursor)
|
||||
self.parent.mouse_hide_timer.start(2000)
|
||||
QtWidgets.QGraphicsView.mouseMoveEvent(self, event)
|
||||
|
||||
def generate_graphicsview_context_menu(self, position):
|
||||
contextMenu = QtWidgets.QMenu()
|
||||
fsToggleAction = dfToggleAction = 'Caesar si viveret, ad remum dareris'
|
||||
|
||||
if self.parent.is_fullscreen:
|
||||
fsToggleAction = contextMenu.addAction(
|
||||
self.main_window.QImageFactory.get_image('view-fullscreen'),
|
||||
self._translate('PliantQGraphicsView', 'Exit fullscreen'))
|
||||
elif not self.main_window.settings['show_bars']:
|
||||
distraction_free_prompt = self._translate(
|
||||
'PliantQGraphicsView', 'Exit Distraction Free mode')
|
||||
|
||||
dfToggleAction = contextMenu.addAction(
|
||||
self.main_window.QImageFactory.get_image('visibility'),
|
||||
distraction_free_prompt)
|
||||
|
||||
saveAction = contextMenu.addAction(
|
||||
self.main_window.QImageFactory.get_image('filesaveas'),
|
||||
self._translate('PliantQGraphicsView', 'Save page as...'))
|
||||
|
||||
view_submenu_string = self._translate('PliantQGraphicsView', 'View')
|
||||
viewSubMenu = contextMenu.addMenu(view_submenu_string)
|
||||
viewSubMenu.setIcon(
|
||||
self.main_window.QImageFactory.get_image('mail-thread-watch'))
|
||||
|
||||
doublePageAction = viewSubMenu.addAction(
|
||||
self.main_window.QImageFactory.get_image('page-double'),
|
||||
self._translate('PliantQGraphicsView', 'Double page mode (D)'))
|
||||
doublePageAction.setCheckable(True)
|
||||
doublePageAction.setChecked(
|
||||
self.main_window.bookToolBar.doublePageButton.isChecked())
|
||||
|
||||
mangaModeAction = viewSubMenu.addAction(
|
||||
self.main_window.QImageFactory.get_image('manga-mode'),
|
||||
self._translate('PliantQGraphicsView', 'Manga mode (M)'))
|
||||
mangaModeAction.setCheckable(True)
|
||||
mangaModeAction.setChecked(
|
||||
self.main_window.bookToolBar.mangaModeButton.isChecked())
|
||||
viewSubMenu.addSeparator()
|
||||
|
||||
zoominAction = viewSubMenu.addAction(
|
||||
self.main_window.QImageFactory.get_image('zoom-in'),
|
||||
self._translate('PliantQGraphicsView', 'Zoom in (+)'))
|
||||
|
||||
zoomoutAction = viewSubMenu.addAction(
|
||||
self.main_window.QImageFactory.get_image('zoom-out'),
|
||||
self._translate('PliantQGraphicsView', 'Zoom out (-)'))
|
||||
|
||||
fitWidthAction = viewSubMenu.addAction(
|
||||
self.main_window.QImageFactory.get_image('zoom-fit-width'),
|
||||
self._translate('PliantQGraphicsView', 'Fit width (W)'))
|
||||
|
||||
bestFitAction = viewSubMenu.addAction(
|
||||
self.main_window.QImageFactory.get_image('zoom-fit-best'),
|
||||
self._translate('PliantQGraphicsView', 'Best fit (B)'))
|
||||
|
||||
originalSizeAction = viewSubMenu.addAction(
|
||||
self.main_window.QImageFactory.get_image('zoom-original'),
|
||||
self._translate('PliantQGraphicsView', 'Original size (O)'))
|
||||
|
||||
bookmarksToggleAction = 'Latin quote 2. Electric Boogaloo.'
|
||||
if not self.main_window.settings['show_bars'] or self.parent.is_fullscreen:
|
||||
bookmarksToggleAction = contextMenu.addAction(
|
||||
self.main_window.QImageFactory.get_image('bookmarks'),
|
||||
self._translate('PliantQGraphicsView', 'Bookmarks'))
|
||||
|
||||
self.common_functions.generate_combo_box_action(contextMenu)
|
||||
|
||||
action = contextMenu.exec_(self.sender().mapToGlobal(position))
|
||||
|
||||
if action == doublePageAction:
|
||||
self.main_window.bookToolBar.doublePageButton.trigger()
|
||||
if action == mangaModeAction:
|
||||
self.main_window.bookToolBar.mangaModeButton.trigger()
|
||||
|
||||
if action == saveAction:
|
||||
dialog_prompt = self._translate('Main_UI', 'Save page as...')
|
||||
extension_string = self._translate('Main_UI', 'Images')
|
||||
save_file = QtWidgets.QFileDialog.getSaveFileName(
|
||||
self, dialog_prompt, self.main_window.settings['last_open_path'],
|
||||
f'{extension_string} (*.png *.jpg *.bmp)')
|
||||
|
||||
if save_file:
|
||||
self.image_pixmap.save(save_file[0])
|
||||
|
||||
if action == bookmarksToggleAction:
|
||||
self.parent.toggle_side_dock(1)
|
||||
if action == dfToggleAction:
|
||||
self.main_window.toggle_distraction_free()
|
||||
if action == fsToggleAction:
|
||||
self.parent.exit_fullscreen()
|
||||
|
||||
view_action_dict = {
|
||||
zoominAction: QtCore.Qt.Key_Plus,
|
||||
zoomoutAction: QtCore.Qt.Key_Minus,
|
||||
fitWidthAction: QtCore.Qt.Key_W,
|
||||
bestFitAction: QtCore.Qt.Key_B,
|
||||
originalSizeAction: QtCore.Qt.Key_O}
|
||||
|
||||
if action in view_action_dict:
|
||||
self.main_window.modify_comic_view(view_action_dict[action])
|
||||
|
||||
def closeEvent(self, *args):
|
||||
# In case the program is closed when a contentView is fullscreened
|
||||
self.main_window.closeEvent()
|
||||
|
||||
def toggle_annotation_mode(self):
|
||||
# The graphics view doesn't currently have annotation functionality
|
||||
# Don't delete this because it's still called
|
||||
pass
|
||||
|
||||
|
||||
class PliantQTextBrowser(QtWidgets.QTextBrowser):
|
||||
def __init__(self, main_window, parent=None):
|
||||
super(PliantQTextBrowser, self).__init__(parent)
|
||||
self._translate = QtCore.QCoreApplication.translate
|
||||
|
||||
self.parent = parent
|
||||
self.main_window = main_window
|
||||
|
||||
self.annotation_mode = False
|
||||
self.annotator = AnnotationPlacement()
|
||||
self.current_annotation = None
|
||||
self.annotation_dict = self.parent.metadata['annotations']
|
||||
|
||||
self.common_functions = PliantWidgetsCommonFunctions(
|
||||
self, self.main_window)
|
||||
|
||||
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(
|
||||
self.generate_textbrowser_context_menu)
|
||||
|
||||
self.setMouseTracking(True)
|
||||
self.verticalScrollBar().sliderMoved.connect(
|
||||
self.record_position)
|
||||
self.ignore_wheel_event = False
|
||||
self.ignore_wheel_event_number = 0
|
||||
|
||||
self.at_end = False
|
||||
|
||||
def wheelEvent(self, event):
|
||||
self.record_position()
|
||||
self.common_functions.wheelEvent(event)
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
QtWidgets.QTextEdit.keyPressEvent(self, event)
|
||||
if event.key() == QtCore.Qt.Key_Space:
|
||||
if self.verticalScrollBar().value() == self.verticalScrollBar().maximum():
|
||||
if self.at_end: # This makes sure the last lines of the chapter don't get skipped
|
||||
self.common_functions.change_chapter(1, True)
|
||||
self.at_end = True
|
||||
else:
|
||||
self.at_end = False
|
||||
self.set_top_line_cleanly()
|
||||
self.record_position()
|
||||
|
||||
def set_top_line_cleanly(self):
|
||||
# Find the cursor position of the top line and move to it
|
||||
find_cursor = self.cursorForPosition(QtCore.QPoint(0, 0))
|
||||
find_cursor.movePosition(
|
||||
find_cursor.position(), QtGui.QTextCursor.KeepAnchor)
|
||||
self.setTextCursor(find_cursor)
|
||||
self.ensureCursorVisible()
|
||||
|
||||
def record_position(self, return_as_bookmark=False):
|
||||
self.parent.metadata['position']['is_read'] = False
|
||||
|
||||
# The y coordinate is set to 10 because 0 tends to make
|
||||
# cursor position a little finicky
|
||||
cursor = self.cursorForPosition(QtCore.QPoint(0, 10))
|
||||
cursor_position = cursor.position()
|
||||
|
||||
# Current block for progress measurement
|
||||
current_block = cursor.block().blockNumber()
|
||||
current_chapter = self.parent.metadata['position']['current_chapter']
|
||||
|
||||
blocks_per_chapter = self.parent.metadata['position']['blocks_per_chapter']
|
||||
block_sum = sum(blocks_per_chapter[:(current_chapter - 1)])
|
||||
block_sum += current_block
|
||||
|
||||
# This 'current_block' refers to the number of
|
||||
# blocks in the book upto this one
|
||||
self.parent.metadata['position']['current_block'] = block_sum
|
||||
self.common_functions.update_model()
|
||||
|
||||
if return_as_bookmark:
|
||||
return (self.parent.metadata['position']['current_chapter'],
|
||||
cursor_position)
|
||||
else:
|
||||
self.parent.metadata['position']['cursor_position'] = cursor_position
|
||||
|
||||
def toggle_annotation_mode(self):
|
||||
if self.annotation_mode:
|
||||
self.annotation_mode = False
|
||||
self.viewport().setCursor(QtCore.Qt.ArrowCursor)
|
||||
self.parent.sideDock.show()
|
||||
self.parent.sideDock.setWindowOpacity(.95)
|
||||
|
||||
self.current_annotation = None
|
||||
self.parent.sideDock.annotations.annotationListView.clearSelection()
|
||||
|
||||
else:
|
||||
self.annotation_mode = True
|
||||
self.viewport().setCursor(QtCore.Qt.IBeamCursor)
|
||||
self.parent.sideDock.hide()
|
||||
|
||||
selected_index = self.parent.sideDock.annotations.annotationListView.currentIndex()
|
||||
self.current_annotation = self.parent.sideDock.annotationModel.data(
|
||||
selected_index, QtCore.Qt.UserRole)
|
||||
logger.info('Selected annotation: ' + self.current_annotation['name'])
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
# This takes care of annotation placement
|
||||
# and addition to the list that holds all current annotations
|
||||
if not self.current_annotation:
|
||||
QtWidgets.QTextBrowser.mouseReleaseEvent(self, event)
|
||||
return
|
||||
|
||||
self.place_annotation(self.current_annotation)
|
||||
self.toggle_annotation_mode()
|
||||
|
||||
def place_annotation(self, annotation):
|
||||
current_chapter = self.parent.metadata['position']['current_chapter']
|
||||
cursor = self.textCursor()
|
||||
cursor_start = cursor.selectionStart()
|
||||
cursor_end = cursor.selectionEnd()
|
||||
annotation_type = 'text_markup'
|
||||
applicable_to = 'text'
|
||||
annotation_components = annotation['components']
|
||||
|
||||
self.annotator.set_current_annotation(
|
||||
annotation_type, annotation_components)
|
||||
|
||||
new_cursor = self.annotator.format_text(
|
||||
cursor, cursor_start, cursor_end)
|
||||
self.setTextCursor(new_cursor)
|
||||
|
||||
# TODO
|
||||
# Maybe use annotation name for a consolidated annotation list
|
||||
|
||||
this_annotation = {
|
||||
'name': annotation['name'],
|
||||
'applicable_to': applicable_to,
|
||||
'type': annotation_type,
|
||||
'cursor': (cursor_start, cursor_end),
|
||||
'components': annotation_components,
|
||||
'note': None}
|
||||
|
||||
try:
|
||||
self.annotation_dict[current_chapter].append(this_annotation)
|
||||
except KeyError:
|
||||
self.annotation_dict[current_chapter] = []
|
||||
self.annotation_dict[current_chapter].append(this_annotation)
|
||||
|
||||
def generate_textbrowser_context_menu(self, position):
|
||||
selection = self.textCursor().selection()
|
||||
selection = selection.toPlainText()
|
||||
|
||||
current_chapter = self.parent.metadata['position']['current_chapter']
|
||||
cursor_at_mouse = self.cursorForPosition(position)
|
||||
annotation_is_present = self.common_functions.annotation_specific(
|
||||
'check', 'text', current_chapter, cursor_at_mouse.position())
|
||||
|
||||
contextMenu = QtWidgets.QMenu()
|
||||
|
||||
# The following cannot be None because a click
|
||||
# outside the menu means that the action variable is None.
|
||||
defineAction = fsToggleAction = dfToggleAction = 'Caesar si viveret, ad remum dareris'
|
||||
searchWikipediaAction = searchYoutubeAction = 'Does anyone know something funny in Latin?'
|
||||
searchAction = searchGoogleAction = bookmarksToggleAction = 'TODO Insert Latin Joke'
|
||||
deleteAnnotationAction = editAnnotationNoteAction = 'Latin quote 2. Electric Boogaloo.'
|
||||
annotationActions = []
|
||||
|
||||
if self.parent.is_fullscreen:
|
||||
fsToggleAction = contextMenu.addAction(
|
||||
self.main_window.QImageFactory.get_image('view-fullscreen'),
|
||||
self._translate('PliantQTextBrowser', 'Exit fullscreen'))
|
||||
elif not self.main_window.settings['show_bars']:
|
||||
distraction_free_prompt = self._translate(
|
||||
'PliantQTextBrowser', 'Exit Distraction Free mode')
|
||||
dfToggleAction = contextMenu.addAction(
|
||||
self.main_window.QImageFactory.get_image('visibility'),
|
||||
distraction_free_prompt)
|
||||
|
||||
if selection and selection != '':
|
||||
first_selected_word = selection.split()[0]
|
||||
elided_selection = selection
|
||||
if len(elided_selection) > 15:
|
||||
elided_selection = elided_selection[:15] + '...'
|
||||
|
||||
define_string = self._translate('PliantQTextBrowser', 'Define')
|
||||
defineAction = contextMenu.addAction(
|
||||
self.main_window.QImageFactory.get_image('view-readermode'),
|
||||
f'{define_string} "{first_selected_word}"')
|
||||
|
||||
search_submenu_string = self._translate('PliantQTextBrowser', 'Search for')
|
||||
searchSubMenu = contextMenu.addMenu(
|
||||
search_submenu_string + f' "{elided_selection}"')
|
||||
searchSubMenu.setIcon(self.main_window.QImageFactory.get_image('search'))
|
||||
|
||||
searchAction = searchSubMenu.addAction(
|
||||
self.main_window.QImageFactory.get_image('search'),
|
||||
self._translate('PliantQTextBrowser', 'In this book'))
|
||||
searchSubMenu.addSeparator()
|
||||
searchGoogleAction = searchSubMenu.addAction(
|
||||
QtGui.QIcon(':/images/Google.png'),
|
||||
'Google')
|
||||
searchWikipediaAction = searchSubMenu.addAction(
|
||||
QtGui.QIcon(':/images/Wikipedia.png'),
|
||||
'Wikipedia')
|
||||
searchYoutubeAction = searchSubMenu.addAction(
|
||||
QtGui.QIcon(':/images/Youtube.png'),
|
||||
'Youtube')
|
||||
|
||||
# Allow adding new annotation from the context menu
|
||||
if not annotation_is_present:
|
||||
annotation_string = self._translate('PliantQTextBrowser', 'Annotate')
|
||||
annotationSubmenu = contextMenu.addMenu(annotation_string)
|
||||
annotationSubmenu.setIcon(
|
||||
self.main_window.QImageFactory.get_image('annotate'))
|
||||
|
||||
saved_annotations = self.parent.main_window.settings['annotations']
|
||||
if not saved_annotations:
|
||||
nope = annotationSubmenu.addAction('<No annotations set>')
|
||||
nope.setEnabled(False)
|
||||
|
||||
for i in saved_annotations:
|
||||
this_action = QtWidgets.QAction(i['name'])
|
||||
# Does not require / support a role
|
||||
this_action.setData(i)
|
||||
annotationActions.append(this_action)
|
||||
annotationSubmenu.addAction(this_action)
|
||||
|
||||
else:
|
||||
searchAction = contextMenu.addAction(
|
||||
self.main_window.QImageFactory.get_image('search'),
|
||||
self._translate('PliantQTextBrowser', 'Search'))
|
||||
|
||||
if annotation_is_present:
|
||||
annotationsubMenu = contextMenu.addMenu('Annotation')
|
||||
annotationsubMenu.setIcon(self.main_window.QImageFactory.get_image('annotate'))
|
||||
|
||||
editAnnotationNoteAction = annotationsubMenu.addAction(
|
||||
self.main_window.QImageFactory.get_image('edit-rename'),
|
||||
self._translate('PliantQTextBrowser', 'Edit note'))
|
||||
deleteAnnotationAction = annotationsubMenu.addAction(
|
||||
self.main_window.QImageFactory.get_image('remove'),
|
||||
self._translate('PliantQTextBrowser', 'Delete annotation'))
|
||||
|
||||
add_bookmark_string = self._translate('PliantQTextBrowser', 'Add Bookmark')
|
||||
addBookMarkAction = contextMenu.addAction(
|
||||
self.main_window.QImageFactory.get_image('bookmark-new'),
|
||||
add_bookmark_string)
|
||||
|
||||
if not self.main_window.settings['show_bars'] or self.parent.is_fullscreen:
|
||||
bookmarksToggleAction = contextMenu.addAction(
|
||||
self.main_window.QImageFactory.get_image('bookmarks'),
|
||||
self._translate('PliantQTextBrowser', 'Bookmarks'))
|
||||
|
||||
self.common_functions.generate_combo_box_action(contextMenu)
|
||||
|
||||
action = contextMenu.exec_(self.sender().mapToGlobal(position))
|
||||
|
||||
if action == addBookMarkAction:
|
||||
self.parent.sideDock.bookmarks.add_bookmark(cursor_at_mouse.position())
|
||||
|
||||
if action == defineAction:
|
||||
self.main_window.definitionDialog.find_definition(selection)
|
||||
|
||||
if action == searchAction:
|
||||
if selection and selection != '':
|
||||
self.parent.sideDock.search.searchLineEdit.setText(selection)
|
||||
self.parent.toggle_side_dock(2, True)
|
||||
|
||||
if action == searchGoogleAction:
|
||||
webbrowser.open_new_tab(
|
||||
f'https://www.google.com/search?q={selection}')
|
||||
if action == searchWikipediaAction:
|
||||
webbrowser.open_new_tab(
|
||||
f'https://en.wikipedia.org/wiki/Special:Search?search={selection}')
|
||||
if action == searchYoutubeAction:
|
||||
webbrowser.open_new_tab(
|
||||
f'https://www.youtube.com/results?search_query={selection}')
|
||||
|
||||
if action in annotationActions:
|
||||
self.place_annotation(action.data())
|
||||
|
||||
if action == editAnnotationNoteAction:
|
||||
self.common_functions.annotation_specific(
|
||||
'note', 'text', current_chapter, cursor_at_mouse.position())
|
||||
if action == deleteAnnotationAction:
|
||||
self.common_functions.annotation_specific(
|
||||
'delete', 'text', current_chapter, cursor_at_mouse.position())
|
||||
|
||||
if action == bookmarksToggleAction:
|
||||
self.parent.toggle_side_dock(0)
|
||||
|
||||
if action == fsToggleAction:
|
||||
self.parent.exit_fullscreen()
|
||||
if action == dfToggleAction:
|
||||
self.main_window.toggle_distraction_free()
|
||||
|
||||
def closeEvent(self, *args):
|
||||
self.main_window.closeEvent()
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if self.annotation_mode:
|
||||
self.viewport().setCursor(QtCore.Qt.IBeamCursor)
|
||||
else:
|
||||
self.viewport().setCursor(QtCore.Qt.ArrowCursor)
|
||||
self.parent.mouse_hide_timer.start(2000)
|
||||
QtWidgets.QTextBrowser.mouseMoveEvent(self, event)
|
||||
|
||||
|
||||
class PliantWidgetsCommonFunctions:
|
||||
def __init__(self, parent_widget, main_window):
|
||||
self.pw = parent_widget
|
||||
self.main_window = main_window
|
||||
self.are_we_doing_images_only = self.pw.parent.are_we_doing_images_only
|
||||
|
||||
def wheelEvent(self, event):
|
||||
ignore_events = 20
|
||||
if self.are_we_doing_images_only:
|
||||
ignore_events = 10
|
||||
|
||||
if self.pw.ignore_wheel_event:
|
||||
self.pw.ignore_wheel_event_number += 1
|
||||
if self.pw.ignore_wheel_event_number > ignore_events:
|
||||
self.pw.ignore_wheel_event = False
|
||||
self.pw.ignore_wheel_event_number = 0
|
||||
return
|
||||
|
||||
if self.are_we_doing_images_only:
|
||||
QtWidgets.QGraphicsView.wheelEvent(self.pw, event)
|
||||
else:
|
||||
QtWidgets.QTextBrowser.wheelEvent(self.pw, event)
|
||||
|
||||
# Since this is a delta on a mouse move event, it cannot ever be 0
|
||||
vertical_pdelta = event.pixelDelta().y()
|
||||
if vertical_pdelta > 0:
|
||||
moving_up = True
|
||||
elif vertical_pdelta < 0:
|
||||
moving_up = False
|
||||
|
||||
if abs(vertical_pdelta) > 80: # Adjust sensitivity here
|
||||
# Implies that no scrollbar movement is possible
|
||||
if self.pw.verticalScrollBar().value() == self.pw.verticalScrollBar().maximum() == 0:
|
||||
if moving_up:
|
||||
self.change_chapter(-1)
|
||||
else:
|
||||
self.change_chapter(1)
|
||||
|
||||
# Implies that the scrollbar is at the bottom
|
||||
elif self.pw.verticalScrollBar().value() == self.pw.verticalScrollBar().maximum():
|
||||
if not moving_up:
|
||||
self.change_chapter(1)
|
||||
|
||||
# Implies scrollbar is at the top
|
||||
elif self.pw.verticalScrollBar().value() == 0:
|
||||
if moving_up:
|
||||
self.change_chapter(-1)
|
||||
|
||||
def change_chapter(self, direction, was_button_pressed=None):
|
||||
current_tab = self.pw.parent
|
||||
current_position = current_tab.metadata['position']['current_chapter']
|
||||
final_position = len(current_tab.metadata['content'])
|
||||
|
||||
# Prevent scrolling below page 1
|
||||
if current_position == 1 and direction == -1:
|
||||
return
|
||||
|
||||
# Prevent scrolling beyond last page
|
||||
if (current_position == final_position) and direction == 1:
|
||||
return
|
||||
|
||||
# Special cases for double page view
|
||||
# Page limits are taken care of by the set_content method
|
||||
def get_modifier():
|
||||
if (not self.main_window.settings['double_page_mode']
|
||||
or not self.are_we_doing_images_only):
|
||||
return 0
|
||||
|
||||
if (current_position == 0 or current_position % 2 == 0):
|
||||
return 0
|
||||
|
||||
if current_position % 2 == 1:
|
||||
return direction
|
||||
|
||||
current_tab.set_content(
|
||||
current_position + direction + get_modifier(), True, True)
|
||||
|
||||
# Set page position depending on if the chapter number is increasing or decreasing
|
||||
if direction == 1 or was_button_pressed:
|
||||
self.pw.verticalScrollBar().setValue(0)
|
||||
else:
|
||||
self.pw.verticalScrollBar().setValue(
|
||||
self.pw.verticalScrollBar().maximum())
|
||||
|
||||
if not was_button_pressed:
|
||||
self.pw.ignore_wheel_event = True
|
||||
|
||||
def load_annotations(self, chapter):
|
||||
try:
|
||||
chapter_annotations = self.pw.annotation_dict[chapter]
|
||||
except KeyError:
|
||||
return
|
||||
|
||||
for i in chapter_annotations:
|
||||
applicable_to = i['applicable_to']
|
||||
annotation_type = i['type']
|
||||
annotation_components = i['components']
|
||||
|
||||
if not self.are_we_doing_images_only and applicable_to == 'text':
|
||||
cursor = self.pw.textCursor()
|
||||
cursor_start = i['cursor'][0]
|
||||
cursor_end = i['cursor'][1]
|
||||
|
||||
self.pw.annotator.set_current_annotation(
|
||||
annotation_type, annotation_components)
|
||||
|
||||
new_cursor = self.pw.annotator.format_text(
|
||||
cursor, cursor_start, cursor_end)
|
||||
self.pw.setTextCursor(new_cursor)
|
||||
|
||||
def clear_annotations(self):
|
||||
if not self.are_we_doing_images_only:
|
||||
cursor = self.pw.textCursor()
|
||||
cursor.setPosition(0)
|
||||
cursor.movePosition(
|
||||
QtGui.QTextCursor.End, QtGui.QTextCursor.KeepAnchor)
|
||||
|
||||
previewCharFormat = QtGui.QTextCharFormat()
|
||||
previewCharFormat.setFontStyleStrategy(
|
||||
QtGui.QFont.PreferAntialias)
|
||||
cursor.setCharFormat(previewCharFormat)
|
||||
cursor.clearSelection()
|
||||
self.pw.setTextCursor(cursor)
|
||||
|
||||
def annotation_specific(self, mode, annotation_type, chapter, cursor_position):
|
||||
try:
|
||||
chapter_annotations = self.pw.annotation_dict[chapter]
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
for i in chapter_annotations:
|
||||
if annotation_type == 'text':
|
||||
cursor_start = i['cursor'][0]
|
||||
cursor_end = i['cursor'][1]
|
||||
|
||||
if cursor_start <= cursor_position <= cursor_end:
|
||||
if mode == 'check':
|
||||
return True
|
||||
if mode == 'delete':
|
||||
self.pw.annotation_dict[chapter].remove(i)
|
||||
if mode == 'note':
|
||||
note = i['note']
|
||||
self.pw.parent.annotationNoteDock.set_annotation(i)
|
||||
self.pw.parent.annotationNoteEdit.setText(note)
|
||||
self.pw.parent.annotationNoteDock.show()
|
||||
|
||||
# Post iteration
|
||||
if mode == 'check':
|
||||
return False
|
||||
if mode == 'delete':
|
||||
scroll_position = self.pw.verticalScrollBar().value()
|
||||
self.clear_annotations()
|
||||
self.load_annotations(chapter)
|
||||
self.pw.verticalScrollBar().setValue(scroll_position)
|
||||
|
||||
def update_model(self):
|
||||
# We're updating the underlying model to have real-time
|
||||
# updates on the read status
|
||||
|
||||
# Set a baseline model index in case the item gets deleted
|
||||
# E.g It's open in a tab and deleted from the library
|
||||
model_index = None
|
||||
start_index = self.main_window.lib_ref.libraryModel.index(0, 0)
|
||||
|
||||
# Find index of the model item that corresponds to the tab
|
||||
model_index = self.main_window.lib_ref.libraryModel.match(
|
||||
start_index,
|
||||
QtCore.Qt.UserRole + 6,
|
||||
self.pw.parent.metadata['hash'],
|
||||
1, QtCore.Qt.MatchExactly)
|
||||
|
||||
if self.are_we_doing_images_only:
|
||||
position_percentage = (
|
||||
self.pw.parent.metadata['position']['current_chapter'] /
|
||||
self.pw.parent.metadata['position']['total_chapters'])
|
||||
else:
|
||||
position_percentage = (
|
||||
self.pw.parent.metadata['position']['current_block'] /
|
||||
self.pw.parent.metadata['position']['total_blocks'])
|
||||
|
||||
# Update position percentage
|
||||
if model_index:
|
||||
self.main_window.lib_ref.libraryModel.setData(
|
||||
model_index[0], position_percentage, QtCore.Qt.UserRole + 7)
|
||||
|
||||
def generate_combo_box_action(self, contextMenu):
|
||||
contextMenu.addSeparator()
|
||||
|
||||
def set_toc_position(tocTree):
|
||||
currentIndex = tocTree.currentIndex()
|
||||
required_position = currentIndex.data(QtCore.Qt.UserRole)
|
||||
self.pw.parent.set_content(required_position, True, True)
|
||||
|
||||
# Create the Combobox / Treeview combination
|
||||
tocComboBox = QtWidgets.QComboBox()
|
||||
tocTree = QtWidgets.QTreeView()
|
||||
tocComboBox.setView(tocTree)
|
||||
tocComboBox.setModel(self.pw.parent.tocModel)
|
||||
tocTree.setRootIsDecorated(False)
|
||||
tocTree.setItemsExpandable(False)
|
||||
tocTree.expandAll()
|
||||
|
||||
# Set the position of the QComboBox
|
||||
self.pw.parent.set_tocBox_index(None, tocComboBox)
|
||||
|
||||
# Make clicking do something
|
||||
tocComboBox.currentIndexChanged.connect(
|
||||
lambda: set_toc_position(tocTree))
|
||||
|
||||
comboboxAction = QtWidgets.QWidgetAction(self.pw)
|
||||
comboboxAction.setDefaultWidget(tocComboBox)
|
||||
contextMenu.addAction(comboboxAction)
|
@@ -1,7 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# This file is a part of Lector, a Qt based ebook reader
|
||||
# Copyright (C) 2017 BasioMeusPuga
|
||||
# Copyright (C) 2017-2019 BasioMeusPuga
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -19,35 +17,80 @@
|
||||
import os
|
||||
import pickle
|
||||
import sqlite3
|
||||
import logging
|
||||
|
||||
from PyQt5 import QtCore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DatabaseInit:
|
||||
def __init__(self, location_prefix):
|
||||
os.makedirs(location_prefix, exist_ok=True)
|
||||
database_path = os.path.join(location_prefix, 'Lector.db')
|
||||
self.database_path = os.path.join(location_prefix, 'Lector.db')
|
||||
|
||||
if not os.path.exists(database_path):
|
||||
self.database = sqlite3.connect(database_path)
|
||||
self.books_table_columns = {
|
||||
'id': 'INTEGER PRIMARY KEY',
|
||||
'Title': 'TEXT',
|
||||
'Author': 'TEXT',
|
||||
'Year': 'INTEGER',
|
||||
'DateAdded': 'BLOB',
|
||||
'Path': 'TEXT',
|
||||
'Position': 'BLOB',
|
||||
'ISBN': 'TEXT',
|
||||
'Tags': 'TEXT',
|
||||
'Hash': 'TEXT',
|
||||
'LastAccessed': 'BLOB',
|
||||
'Bookmarks': 'BLOB',
|
||||
'CoverImage': 'BLOB',
|
||||
'Addition': 'TEXT',
|
||||
'Annotations': 'BLOB'}
|
||||
|
||||
self.directories_table_columns = {
|
||||
'id': 'INTEGER PRIMARY KEY',
|
||||
'Path': 'TEXT',
|
||||
'Name': 'TEXT',
|
||||
'Tags': 'TEXT',
|
||||
'CheckState': 'INTEGER'}
|
||||
|
||||
if os.path.exists(self.database_path):
|
||||
self.check_columns()
|
||||
else:
|
||||
self.create_database()
|
||||
|
||||
def create_database(self):
|
||||
# TODO
|
||||
# Add separate columns for:
|
||||
# addition mode
|
||||
self.database.execute(
|
||||
"CREATE TABLE books \
|
||||
(id INTEGER PRIMARY KEY, Title TEXT, Author TEXT, Year INTEGER, DateAdded BLOB, \
|
||||
Path TEXT, Position BLOB, ISBN TEXT, Tags TEXT, Hash TEXT, LastAccessed BLOB,\
|
||||
Bookmarks BLOB, CoverImage BLOB)")
|
||||
self.database = sqlite3.connect(self.database_path)
|
||||
|
||||
column_string = ', '.join(
|
||||
[i[0] + ' ' + i[1] for i in self.books_table_columns.items()])
|
||||
self.database.execute(f"CREATE TABLE books ({column_string})")
|
||||
|
||||
# CheckState is the standard QtCore.Qt.Checked / Unchecked
|
||||
self.database.execute(
|
||||
"CREATE TABLE directories (id INTEGER PRIMARY KEY, Path TEXT, \
|
||||
Name TEXT, Tags TEXT, CheckState INTEGER)")
|
||||
column_string = ', '.join(
|
||||
[i[0] + ' ' + i[1] for i in self.directories_table_columns.items()])
|
||||
self.database.execute(f"CREATE TABLE directories ({column_string})")
|
||||
|
||||
self.database.commit()
|
||||
self.database.close()
|
||||
|
||||
def check_columns(self):
|
||||
self.database = sqlite3.connect(self.database_path)
|
||||
|
||||
database_return = self.database.execute("PRAGMA table_info(books)").fetchall()
|
||||
database_columns = [i[1] for i in database_return]
|
||||
|
||||
# This allows for addition of a column without having to reform the database
|
||||
commit_required = False
|
||||
for i in self.books_table_columns.items():
|
||||
if i[0] not in database_columns:
|
||||
commit_required = True
|
||||
info_string = f'Database: Adding column "{i[0]}"'
|
||||
logger.info(info_string)
|
||||
sql_command = f"ALTER TABLE books ADD COLUMN {i[0]} {i[1]}"
|
||||
self.database.execute(sql_command)
|
||||
|
||||
if commit_required:
|
||||
self.database.commit()
|
||||
|
||||
|
||||
class DatabaseFunctions:
|
||||
def __init__(self, location_prefix):
|
||||
@@ -55,10 +98,6 @@ class DatabaseFunctions:
|
||||
self.database = sqlite3.connect(database_path)
|
||||
|
||||
def set_library_paths(self, data_iterable):
|
||||
# TODO
|
||||
# INSERT OR REPLACE is not working
|
||||
# So this is the old fashion kitchen sink approach
|
||||
|
||||
self.database.execute("DELETE FROM directories")
|
||||
|
||||
for i in data_iterable:
|
||||
@@ -67,10 +106,13 @@ class DatabaseFunctions:
|
||||
tags = i[2]
|
||||
is_checked = i[3]
|
||||
|
||||
if not os.path.exists(path):
|
||||
continue # Remove invalid paths from the database
|
||||
|
||||
sql_command = (
|
||||
"INSERT OR REPLACE INTO directories (ID, Path, Name, Tags, CheckState)\
|
||||
VALUES ((SELECT ID FROM directories WHERE Path = ?), ?, ?, ?, ?)")
|
||||
self.database.execute(sql_command, [path, path, name, tags, is_checked])
|
||||
"INSERT INTO directories (Path, Name, Tags, CheckState)\
|
||||
VALUES (?, ?, ?, ?)")
|
||||
self.database.execute(sql_command, [path, name, tags, is_checked])
|
||||
|
||||
self.database.commit()
|
||||
self.database.close()
|
||||
@@ -95,6 +137,7 @@ class DatabaseFunctions:
|
||||
path = i[1]['path']
|
||||
cover = i[1]['cover_image']
|
||||
isbn = i[1]['isbn']
|
||||
addition_mode = i[1]['addition_mode']
|
||||
tags = i[1]['tags']
|
||||
if tags:
|
||||
# Is a list. Needs to be a string
|
||||
@@ -105,8 +148,9 @@ class DatabaseFunctions:
|
||||
|
||||
sql_command_add = (
|
||||
"INSERT OR REPLACE INTO \
|
||||
books (Title, Author, Year, DateAdded, Path, ISBN, Tags, Hash, CoverImage) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
books (Title, Author, Year, DateAdded, Path, \
|
||||
ISBN, Tags, Hash, CoverImage, Addition) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
|
||||
cover_insert = None
|
||||
if cover:
|
||||
@@ -115,7 +159,8 @@ class DatabaseFunctions:
|
||||
self.database.execute(
|
||||
sql_command_add,
|
||||
[title, author, year, current_datetime_bin,
|
||||
path, isbn, tags, book_hash, cover_insert])
|
||||
path, isbn, tags, book_hash, cover_insert,
|
||||
addition_mode])
|
||||
|
||||
self.database.commit()
|
||||
self.database.close()
|
||||
@@ -165,8 +210,9 @@ class DatabaseFunctions:
|
||||
else:
|
||||
return None
|
||||
|
||||
except (KeyError, sqlite3.OperationalError):
|
||||
print('SQLite is in wretched rebellion @ data fetching handling')
|
||||
except Exception as e:
|
||||
error_string = 'SQLite is in wretched rebellion @ data fetching handling'
|
||||
logger.critical(error_string + f' {type(e).__name__} Arguments: {e.args}')
|
||||
|
||||
def fetch_covers_only(self, hash_list):
|
||||
parameter_marks = ','.join(['?' for i in hash_list])
|
||||
@@ -177,7 +223,7 @@ class DatabaseFunctions:
|
||||
|
||||
def modify_metadata(self, metadata_dict, book_hash):
|
||||
def generate_binary(column, data):
|
||||
if column in ('Position', 'LastAccessed', 'Bookmarks'):
|
||||
if column in ('Position', 'LastAccessed', 'Bookmarks', 'Annotations'):
|
||||
return sqlite3.Binary(pickle.dumps(data))
|
||||
elif column == 'CoverImage':
|
||||
return sqlite3.Binary(data)
|
||||
@@ -198,8 +244,9 @@ class DatabaseFunctions:
|
||||
try:
|
||||
self.database.execute(
|
||||
sql_command, update_data)
|
||||
except sqlite3.OperationalError:
|
||||
print('SQLite is in wretched rebellion @ metadata handling')
|
||||
except sqlite3.OperationalError as e:
|
||||
error_string = 'SQLite is in wretched rebellion @ metadata handling'
|
||||
logger.critical(error_string + f' {type(e).__name__} Arguments: {e.args}')
|
||||
|
||||
self.database.commit()
|
||||
self.database.close()
|
||||
@@ -208,9 +255,10 @@ class DatabaseFunctions:
|
||||
# target_data is an iterable
|
||||
|
||||
if column_name == '*':
|
||||
self.database.execute('DELETE FROM books')
|
||||
self.database.execute(
|
||||
"DELETE FROM books WHERE NOT Addition = 'manual'")
|
||||
else:
|
||||
sql_command = f'DELETE FROM books WHERE {column_name} = ?'
|
||||
sql_command = f"DELETE FROM books WHERE {column_name} = ?"
|
||||
for i in target_data:
|
||||
self.database.execute(sql_command, (i,))
|
||||
|
||||
|
@@ -1,7 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# This file is a part of Lector, a Qt based ebook reader
|
||||
# Copyright (C) 2017 BasioMeusPuga
|
||||
# Copyright (C) 2017-2019 BasioMeusPuga
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -16,18 +14,33 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import requests
|
||||
from PyQt5 import QtWidgets, QtCore, QtGui, QtMultimedia
|
||||
import json
|
||||
import logging
|
||||
import urllib.request
|
||||
|
||||
from resources import definitions
|
||||
from PyQt5 import QtWidgets, QtCore, QtGui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from PyQt5 import QtMultimedia
|
||||
multimedia_available = True
|
||||
except ImportError:
|
||||
error_string = 'QtMultimedia not found. Sounds will not play.'
|
||||
logger.error(error_string)
|
||||
multimedia_available = False
|
||||
|
||||
from lector.resources import definitions
|
||||
|
||||
|
||||
class DefinitionsUI(QtWidgets.QDialog, definitions.Ui_Dialog):
|
||||
def __init__(self, parent):
|
||||
super(DefinitionsUI, self).__init__()
|
||||
self.setupUi(self)
|
||||
self._translate = QtCore.QCoreApplication.translate
|
||||
|
||||
self.parent = parent
|
||||
self.previous_position = None
|
||||
|
||||
self.setWindowFlags(
|
||||
QtCore.Qt.Popup |
|
||||
@@ -36,8 +49,14 @@ class DefinitionsUI(QtWidgets.QDialog, definitions.Ui_Dialog):
|
||||
radius = 15
|
||||
path = QtGui.QPainterPath()
|
||||
path.addRoundedRect(QtCore.QRectF(self.rect()), radius, radius)
|
||||
mask = QtGui.QRegion(path.toFillPolygon().toPolygon())
|
||||
self.setMask(mask)
|
||||
|
||||
try:
|
||||
mask = QtGui.QRegion(path.toFillPolygon().toPolygon())
|
||||
self.setMask(mask)
|
||||
except TypeError: # Required for older versions of Qt
|
||||
pass
|
||||
|
||||
self.definitionView.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
|
||||
|
||||
self.app_id = 'bb7a91f9'
|
||||
self.app_key = 'fefacdf6775c347b52e9efa2efe642ef'
|
||||
@@ -48,25 +67,35 @@ class DefinitionsUI(QtWidgets.QDialog, definitions.Ui_Dialog):
|
||||
self.pronunciation_mp3 = None
|
||||
|
||||
self.okButton.clicked.connect(self.hide)
|
||||
self.pronounceButton.clicked.connect(self.play_pronunciation)
|
||||
self.dialogBackground.clicked.connect(self.color_background)
|
||||
if multimedia_available:
|
||||
self.pronounceButton.clicked.connect(self.play_pronunciation)
|
||||
else:
|
||||
self.pronounceButton.setEnabled(False)
|
||||
|
||||
def api_call(self, url, word):
|
||||
language = self.parent.settings['dictionary_language']
|
||||
url = url + language + '/' + word.lower()
|
||||
|
||||
r = requests.get(
|
||||
url,
|
||||
headers={'app_id': self.app_id, 'app_key': self.app_key})
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header('app_id', self.app_id)
|
||||
req.add_header('app_key', self.app_key)
|
||||
|
||||
if r.status_code != 200:
|
||||
print('A firm nope on the dictionary finding thing')
|
||||
try:
|
||||
response = urllib.request.urlopen(req)
|
||||
if response.getcode() == 200:
|
||||
return_json = json.loads(response.read())
|
||||
return return_json
|
||||
except Exception as e:
|
||||
this_error = f'API access error'
|
||||
logger.exception(this_error + f' {type(e).__name__} Arguments: {e.args}')
|
||||
self.parent.display_error_notification(None)
|
||||
return None
|
||||
|
||||
return r.json()
|
||||
|
||||
def find_definition(self, word):
|
||||
word_root_json = self.api_call(self.root_url, word)
|
||||
if not word_root_json:
|
||||
logger.error('Word root json noped out: ' + word)
|
||||
self.set_text(word, None, None, True)
|
||||
return
|
||||
|
||||
@@ -75,6 +104,8 @@ class DefinitionsUI(QtWidgets.QDialog, definitions.Ui_Dialog):
|
||||
|
||||
definition_json = self.api_call(self.define_url, word_root)
|
||||
if not definition_json:
|
||||
logger.error('Definition json noped out: ' + word_root)
|
||||
self.set_text(word, None, None, True)
|
||||
return
|
||||
|
||||
definitions = {}
|
||||
@@ -92,7 +123,7 @@ class DefinitionsUI(QtWidgets.QDialog, definitions.Ui_Dialog):
|
||||
this_definition = j['definitions'][0].capitalize()
|
||||
except KeyError:
|
||||
# The API also reports crossReferenceMarkers here
|
||||
pass
|
||||
this_definition = '<Not found>'
|
||||
|
||||
try:
|
||||
definitions[category].add(this_definition)
|
||||
@@ -109,8 +140,9 @@ class DefinitionsUI(QtWidgets.QDialog, definitions.Ui_Dialog):
|
||||
html_string += f'<h2><em><strong>{word}</strong></em></h2>\n'
|
||||
|
||||
if nothing_found:
|
||||
nope_string = self._translate('DefinitionsUI', 'No definitions found in')
|
||||
language = self.parent.settings['dictionary_language'].upper()
|
||||
html_string += f'<p><em>No definitions found in {language}<em></p>\n'
|
||||
html_string += f'<p><em>{nope_string} {language}<em></p>\n'
|
||||
else:
|
||||
# Word root
|
||||
html_string += f'<p><em>Word root: <em>{word_root}</p>\n'
|
||||
@@ -133,18 +165,30 @@ class DefinitionsUI(QtWidgets.QDialog, definitions.Ui_Dialog):
|
||||
background = self.parent.settings['dialog_background']
|
||||
else:
|
||||
self.previous_position = self.pos()
|
||||
background = self.parent.get_color()
|
||||
self.parent.get_color()
|
||||
background = self.parent.settings['dialog_background']
|
||||
|
||||
# Calculate inverse color for the background so that
|
||||
# the text doesn't look blank
|
||||
r, g, b, alpha = background.getRgb()
|
||||
inv_average = 255 - (r + g + b) // 3
|
||||
if 100 < inv_average < 150:
|
||||
inv_average = 255
|
||||
|
||||
foreground = QtGui.QColor(
|
||||
inv_average, inv_average, inv_average, alpha)
|
||||
|
||||
self.setStyleSheet(
|
||||
"QDialog {{background-color: {0}}}".format(background.name()))
|
||||
self.definitionView.setStyleSheet(
|
||||
"QTextBrowser {{background-color: {0}}}".format(background.name()))
|
||||
"QTextBrowser {{color:{0}; background-color: {1}}}".format(
|
||||
foreground.name(), background.name()))
|
||||
|
||||
if not set_initial:
|
||||
self.show()
|
||||
|
||||
def play_pronunciation(self):
|
||||
if not self.pronunciation_mp3:
|
||||
if not self.pronunciation_mp3 or not multimedia_available:
|
||||
return
|
||||
|
||||
media_content = QtMultimedia.QMediaContent(
|
||||
|
@@ -1,7 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# This file is a part of Lector, a Qt based ebook reader
|
||||
# Copyright (C) 2017 BasioMeusPuga
|
||||
# Copyright (C) 2017-2019 BasioMeusPuga
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -16,8 +14,13 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import logging
|
||||
|
||||
from PyQt5 import QtWidgets, QtGui, QtCore
|
||||
from resources import pie_chart
|
||||
|
||||
from lector.resources import pie_chart
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LibraryDelegate(QtWidgets.QStyledItemDelegate):
|
||||
@@ -34,11 +37,7 @@ class LibraryDelegate(QtWidgets.QStyledItemDelegate):
|
||||
|
||||
option = option.__class__(option)
|
||||
file_exists = index.data(QtCore.Qt.UserRole + 5)
|
||||
metadata = index.data(QtCore.Qt.UserRole + 3)
|
||||
|
||||
position = metadata['position']
|
||||
if position:
|
||||
is_read = position['is_read']
|
||||
position_percent = index.data(QtCore.Qt.UserRole + 7)
|
||||
|
||||
# The shadow pixmap currently is set to 420 x 600
|
||||
# Only draw the cover shadow in case the setting is enabled
|
||||
@@ -55,55 +54,20 @@ class LibraryDelegate(QtWidgets.QStyledItemDelegate):
|
||||
if not file_exists:
|
||||
painter.setOpacity(.7)
|
||||
QtWidgets.QStyledItemDelegate.paint(self, painter, option, index)
|
||||
read_icon = pie_chart.pixmapper(-1, None, None, 36)
|
||||
painter.setOpacity(1)
|
||||
read_icon = pie_chart.pixmapper(
|
||||
-1, None, self.parent.settings['consider_read_at'], 36)
|
||||
x_draw = option.rect.bottomRight().x() - 30
|
||||
y_draw = option.rect.bottomRight().y() - 35
|
||||
painter.drawPixmap(x_draw, y_draw, read_icon)
|
||||
painter.setOpacity(1)
|
||||
return
|
||||
|
||||
QtWidgets.QStyledItemDelegate.paint(self, painter, option, index)
|
||||
if position:
|
||||
if is_read:
|
||||
current_chapter = total_chapters = 100
|
||||
else:
|
||||
try:
|
||||
current_chapter = position['current_chapter']
|
||||
total_chapters = position['total_chapters']
|
||||
except KeyError:
|
||||
return
|
||||
|
||||
if position_percent:
|
||||
read_icon = pie_chart.pixmapper(
|
||||
current_chapter, total_chapters, self.temp_dir, 36)
|
||||
position_percent, self.temp_dir, self.parent.settings['consider_read_at'], 36)
|
||||
|
||||
x_draw = option.rect.bottomRight().x() - 30
|
||||
y_draw = option.rect.bottomRight().y() - 35
|
||||
if current_chapter != 1:
|
||||
painter.drawPixmap(x_draw, y_draw, read_icon)
|
||||
|
||||
|
||||
class BookmarkDelegate(QtWidgets.QStyledItemDelegate):
|
||||
def __init__(self, parent=None):
|
||||
super(BookmarkDelegate, self).__init__(parent)
|
||||
self.parent = parent
|
||||
|
||||
def sizeHint(self, *args):
|
||||
dockwidget_width = self.parent.width() - 20
|
||||
return QtCore.QSize(dockwidget_width, 50)
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
# TODO
|
||||
# Alignment of the painted item
|
||||
|
||||
option = option.__class__(option)
|
||||
|
||||
chapter_index = index.data(QtCore.Qt.UserRole)
|
||||
chapter_name = self.parent.window().bookToolBar.tocBox.itemText(chapter_index - 1)
|
||||
if len(chapter_name) > 25:
|
||||
chapter_name = chapter_name[:25] + '...'
|
||||
|
||||
QtWidgets.QStyledItemDelegate.paint(self, painter, option, index)
|
||||
painter.drawText(
|
||||
option.rect,
|
||||
QtCore.Qt.AlignBottom | QtCore.Qt.AlignRight | QtCore.Qt.TextWordWrap,
|
||||
' ' + chapter_name)
|
||||
painter.drawPixmap(x_draw, y_draw, read_icon)
|
||||
|
520
lector/dockwidgets.py
Normal file
@@ -0,0 +1,520 @@
|
||||
# This file is a part of Lector, a Qt based ebook reader
|
||||
# Copyright (C) 2017-2019 BasioMeusPuga
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import uuid
|
||||
|
||||
from PyQt5 import QtWidgets, QtGui, QtCore
|
||||
|
||||
from lector.models import BookmarkProxyModel
|
||||
from lector.threaded import BackGroundTextSearch
|
||||
|
||||
|
||||
class PliantDockWidget(QtWidgets.QDockWidget):
|
||||
def __init__(self, main_window, notes_only, contentView, parent=None):
|
||||
super(PliantDockWidget, self).__init__(parent)
|
||||
self.main_window = main_window
|
||||
self.notes_only = notes_only
|
||||
self.contentView = contentView
|
||||
self.current_annotation = None
|
||||
self.parent = parent
|
||||
|
||||
# Models
|
||||
# The following models belong to the sideDock
|
||||
# bookmarkModel, bookmarkProxyModel
|
||||
# annotationModel
|
||||
# searchResultsModel
|
||||
self.bookmarkModel = None
|
||||
self.bookmarkProxyModel = None
|
||||
self.annotationModel = None
|
||||
self.searchResultsModel = None
|
||||
|
||||
# References
|
||||
# All widgets belong to these
|
||||
self.bookmarks = None
|
||||
self.annotations = None
|
||||
self.search = None
|
||||
|
||||
# Widgets
|
||||
# Except this one
|
||||
self.sideDockTabWidget = None
|
||||
|
||||
def showEvent(self, event=None):
|
||||
viewport_topRight = self.contentView.mapToGlobal(
|
||||
self.contentView.viewport().rect().topRight())
|
||||
|
||||
desktop_size = QtWidgets.QDesktopWidget().screenGeometry()
|
||||
dock_y = viewport_topRight.y()
|
||||
dock_height = self.contentView.viewport().size().height()
|
||||
|
||||
if self.notes_only:
|
||||
dock_width = dock_height = desktop_size.width() // 5.5
|
||||
dock_x = QtGui.QCursor.pos().x()
|
||||
dock_y = QtGui.QCursor.pos().y()
|
||||
else:
|
||||
dock_width = desktop_size.width() // 5
|
||||
dock_x = viewport_topRight.x() - dock_width + 1
|
||||
|
||||
self.main_window.active_docks.append(self)
|
||||
self.setGeometry(dock_x, dock_y, dock_width, dock_height)
|
||||
|
||||
def hideEvent(self, event=None):
|
||||
if self.notes_only:
|
||||
annotationNoteEdit = self.findChild(QtWidgets.QTextEdit)
|
||||
if self.current_annotation:
|
||||
self.current_annotation['note'] = annotationNoteEdit.toPlainText()
|
||||
|
||||
try:
|
||||
self.main_window.active_docks.remove(self)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def set_annotation(self, annotation):
|
||||
self.current_annotation = annotation
|
||||
|
||||
def populate(self):
|
||||
self.setFeatures(QtWidgets.QDockWidget.DockWidgetClosable)
|
||||
self.setTitleBarWidget(QtWidgets.QWidget(self)) # Removes titlebar
|
||||
self.sideDockTabWidget = QtWidgets.QTabWidget(self)
|
||||
self.setWidget(self.sideDockTabWidget)
|
||||
|
||||
# This order is important
|
||||
self.bookmarkModel = QtGui.QStandardItemModel(self)
|
||||
self.bookmarkProxyModel = BookmarkProxyModel(self)
|
||||
self.bookmarks = Bookmarks(self)
|
||||
self.bookmarks.generate_bookmark_model()
|
||||
|
||||
if not self.parent.are_we_doing_images_only:
|
||||
self.annotationModel = QtGui.QStandardItemModel(self)
|
||||
self.annotations = Annotations(self)
|
||||
self.annotations.generate_annotation_model()
|
||||
|
||||
self.searchResultsModel = QtGui.QStandardItemModel(self)
|
||||
self.search = Search(self)
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.hide()
|
||||
# Ignoring this event prevents application closure
|
||||
# when everything is fullscreened
|
||||
event.ignore()
|
||||
|
||||
|
||||
# For the following classes, the parent is the sideDock
|
||||
# The parentTab is the parent... tab. So self.parent.parent
|
||||
class Bookmarks:
|
||||
def __init__(self, parent):
|
||||
self.parent = parent
|
||||
self.parentTab = self.parent.parent
|
||||
self.bookmarkTreeView = QtWidgets.QTreeView(self.parent)
|
||||
|
||||
self._translate = QtCore.QCoreApplication.translate
|
||||
self.bookmarks_string = self._translate('SideDock', 'Bookmarks')
|
||||
self.bookmark_default = self._translate('SideDock', 'New bookmark')
|
||||
|
||||
self.create_widgets()
|
||||
|
||||
def create_widgets(self):
|
||||
self.bookmarkTreeView.setHeaderHidden(True)
|
||||
self.bookmarkTreeView.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
self.bookmarkTreeView.customContextMenuRequested.connect(
|
||||
self.generate_bookmark_context_menu)
|
||||
self.bookmarkTreeView.clicked.connect(self.navigate_to_bookmark)
|
||||
|
||||
# Add widget to side dock
|
||||
self.parent.sideDockTabWidget.addTab(
|
||||
self.bookmarkTreeView, self.bookmarks_string)
|
||||
|
||||
def add_bookmark(self, position=None):
|
||||
identifier = uuid.uuid4().hex[:10]
|
||||
|
||||
if self.parentTab.are_we_doing_images_only:
|
||||
chapter = self.parentTab.metadata['position']['current_chapter']
|
||||
cursor_position = 0
|
||||
else:
|
||||
chapter, cursor_position = self.parent.contentView.record_position(True)
|
||||
if position: # Should be the case when called from the context menu
|
||||
cursor_position = position
|
||||
|
||||
self.parentTab.metadata['bookmarks'][identifier] = {
|
||||
'chapter': chapter,
|
||||
'cursor_position': cursor_position,
|
||||
'description': self.bookmark_default}
|
||||
|
||||
self.parent.setVisible(True)
|
||||
self.parent.sideDockTabWidget.setCurrentIndex(0)
|
||||
self.add_bookmark_to_model(
|
||||
self.bookmark_default, chapter, cursor_position, identifier, True)
|
||||
|
||||
def add_bookmark_to_model(
|
||||
self, description, chapter_number, cursor_position,
|
||||
identifier, new_bookmark=False):
|
||||
|
||||
def edit_new_bookmark(parent_item):
|
||||
new_child = parent_item.child(parent_item.rowCount() - 1, 0)
|
||||
source_index = self.parent.bookmarkModel.indexFromItem(new_child)
|
||||
edit_index = self.bookmarkTreeView.model().mapFromSource(source_index)
|
||||
self.parent.activateWindow()
|
||||
self.bookmarkTreeView.setFocus()
|
||||
self.bookmarkTreeView.setCurrentIndex(edit_index)
|
||||
self.bookmarkTreeView.edit(edit_index)
|
||||
|
||||
def get_chapter_name(chapter_number):
|
||||
for i in reversed(self.parentTab.metadata['toc']):
|
||||
if i[2] <= chapter_number:
|
||||
return i[1]
|
||||
return 'Unknown'
|
||||
|
||||
bookmark = QtGui.QStandardItem()
|
||||
bookmark.setData(False, QtCore.Qt.UserRole + 10) # Is Parent
|
||||
bookmark.setData(chapter_number, QtCore.Qt.UserRole) # Chapter number
|
||||
bookmark.setData(cursor_position, QtCore.Qt.UserRole + 1) # Cursor Position
|
||||
bookmark.setData(identifier, QtCore.Qt.UserRole + 2) # Identifier
|
||||
bookmark.setData(description, QtCore.Qt.DisplayRole) # Description
|
||||
bookmark_chapter_name = get_chapter_name(chapter_number)
|
||||
|
||||
for i in range(self.parent.bookmarkModel.rowCount()):
|
||||
parentIndex = self.parent.bookmarkModel.index(i, 0)
|
||||
parent_chapter_number = parentIndex.data(QtCore.Qt.UserRole)
|
||||
parent_chapter_name = parentIndex.data(QtCore.Qt.DisplayRole)
|
||||
|
||||
# This prevents duplication of the bookmark in the new
|
||||
# navigation model
|
||||
if ((parent_chapter_number <= chapter_number) and
|
||||
(parent_chapter_name == bookmark_chapter_name)):
|
||||
bookmarkParent = self.parent.bookmarkModel.itemFromIndex(parentIndex)
|
||||
bookmarkParent.appendRow(bookmark)
|
||||
if new_bookmark:
|
||||
edit_new_bookmark(bookmarkParent)
|
||||
return
|
||||
|
||||
# In case no parent item exists
|
||||
bookmarkParent = QtGui.QStandardItem()
|
||||
bookmarkParent.setData(True, QtCore.Qt.UserRole + 10) # Is Parent
|
||||
bookmarkParent.setFlags(bookmarkParent.flags() & ~QtCore.Qt.ItemIsEditable) # Is Editable
|
||||
bookmarkParent.setData(get_chapter_name(chapter_number), QtCore.Qt.DisplayRole)
|
||||
bookmarkParent.setData(chapter_number, QtCore.Qt.UserRole)
|
||||
|
||||
bookmarkParent.appendRow(bookmark)
|
||||
self.parent.bookmarkModel.appendRow(bookmarkParent)
|
||||
if new_bookmark:
|
||||
edit_new_bookmark(bookmarkParent)
|
||||
|
||||
def navigate_to_bookmark(self, index):
|
||||
if not index.isValid():
|
||||
return
|
||||
|
||||
is_parent = self.parent.bookmarkProxyModel.data(
|
||||
index, QtCore.Qt.UserRole + 10)
|
||||
if is_parent:
|
||||
chapter_number = self.parent.bookmarkProxyModel.data(
|
||||
index, QtCore.Qt.UserRole)
|
||||
self.parentTab.set_content(chapter_number, True, True)
|
||||
return
|
||||
|
||||
chapter = self.parent.bookmarkProxyModel.data(
|
||||
index, QtCore.Qt.UserRole)
|
||||
cursor_position = self.parent.bookmarkProxyModel.data(
|
||||
index, QtCore.Qt.UserRole + 1)
|
||||
|
||||
self.parentTab.set_content(chapter, True, True)
|
||||
if not self.parentTab.are_we_doing_images_only:
|
||||
self.parentTab.set_cursor_position(cursor_position)
|
||||
|
||||
def generate_bookmark_model(self):
|
||||
for i in self.parentTab.metadata['bookmarks'].items():
|
||||
description = i[1]['description']
|
||||
chapter = i[1]['chapter']
|
||||
cursor_position = i[1]['cursor_position']
|
||||
identifier = i[0]
|
||||
self.add_bookmark_to_model(
|
||||
description, chapter, cursor_position, identifier)
|
||||
|
||||
self.generate_bookmark_proxy_model()
|
||||
|
||||
def generate_bookmark_proxy_model(self):
|
||||
self.parent.bookmarkProxyModel.setSourceModel(self.parent.bookmarkModel)
|
||||
self.parent.bookmarkProxyModel.setSortCaseSensitivity(False)
|
||||
self.parent.bookmarkProxyModel.setSortRole(QtCore.Qt.UserRole)
|
||||
self.parent.bookmarkProxyModel.sort(0)
|
||||
self.bookmarkTreeView.setModel(self.parent.bookmarkProxyModel)
|
||||
|
||||
def generate_bookmark_context_menu(self, position):
|
||||
index = self.bookmarkTreeView.indexAt(position)
|
||||
if not index.isValid():
|
||||
return
|
||||
|
||||
is_parent = self.parent.bookmarkProxyModel.data(
|
||||
index, QtCore.Qt.UserRole + 10)
|
||||
if is_parent:
|
||||
return
|
||||
|
||||
bookmarkMenu = QtWidgets.QMenu()
|
||||
editAction = bookmarkMenu.addAction(
|
||||
self.parentTab.main_window.QImageFactory.get_image('edit-rename'),
|
||||
self._translate('Tab', 'Edit'))
|
||||
deleteAction = bookmarkMenu.addAction(
|
||||
self.parentTab.main_window.QImageFactory.get_image('trash-empty'),
|
||||
self._translate('Tab', 'Delete'))
|
||||
|
||||
action = bookmarkMenu.exec_(
|
||||
self.bookmarkTreeView.mapToGlobal(position))
|
||||
|
||||
if action == editAction:
|
||||
self.bookmarkTreeView.edit(index)
|
||||
|
||||
if action == deleteAction:
|
||||
child_index = self.parent.bookmarkProxyModel.mapToSource(index)
|
||||
parent_index = child_index.parent()
|
||||
child_rows = self.parent.bookmarkModel.itemFromIndex(
|
||||
parent_index).rowCount()
|
||||
delete_uuid = self.parent.bookmarkModel.data(
|
||||
child_index, QtCore.Qt.UserRole + 2)
|
||||
|
||||
self.parentTab.metadata['bookmarks'].pop(delete_uuid)
|
||||
|
||||
self.parent.bookmarkModel.removeRow(
|
||||
child_index.row(), child_index.parent())
|
||||
if child_rows == 1:
|
||||
self.parent.bookmarkModel.removeRow(parent_index.row())
|
||||
|
||||
|
||||
class Annotations:
|
||||
def __init__(self, parent):
|
||||
self.parent = parent
|
||||
self.parentTab = self.parent.parent
|
||||
self.annotationListView = QtWidgets.QListView(self.parent)
|
||||
|
||||
self._translate = QtCore.QCoreApplication.translate
|
||||
self.annotations_string = self._translate('SideDock', 'Annotations')
|
||||
|
||||
self.create_widgets()
|
||||
|
||||
def create_widgets(self):
|
||||
self.annotationListView.setEditTriggers(QtWidgets.QListView.NoEditTriggers)
|
||||
self.annotationListView.doubleClicked.connect(
|
||||
self.parent.contentView.toggle_annotation_mode)
|
||||
|
||||
# Add widget to side dock
|
||||
self.parent.sideDockTabWidget.addTab(
|
||||
self.annotationListView, self.annotations_string)
|
||||
|
||||
def generate_annotation_model(self):
|
||||
# TODO
|
||||
# Annotation previews will require creation of a
|
||||
# QStyledItemDelegate
|
||||
|
||||
saved_annotations = self.parent.main_window.settings['annotations']
|
||||
if not saved_annotations:
|
||||
return
|
||||
|
||||
# Create annotation model
|
||||
for i in saved_annotations:
|
||||
item = QtGui.QStandardItem()
|
||||
item.setText(i['name'])
|
||||
item.setData(i, QtCore.Qt.UserRole)
|
||||
self.parent.annotationModel.appendRow(item)
|
||||
self.annotationListView.setModel(self.parent.annotationModel)
|
||||
|
||||
|
||||
class Search:
|
||||
def __init__(self, parent):
|
||||
self.parent = parent
|
||||
self.parentTab = self.parent.parent
|
||||
|
||||
self.searchThread = BackGroundTextSearch()
|
||||
self.searchOptionsLayout = QtWidgets.QHBoxLayout()
|
||||
self.searchTabLayout = QtWidgets.QVBoxLayout()
|
||||
self.searchTimer = QtCore.QTimer(self.parent)
|
||||
self.searchLineEdit = QtWidgets.QLineEdit(self.parent)
|
||||
self.searchBookButton = QtWidgets.QToolButton(self.parent)
|
||||
self.caseSensitiveSearchButton = QtWidgets.QToolButton(self.parent)
|
||||
self.matchWholeWordButton = QtWidgets.QToolButton(self.parent)
|
||||
self.searchResultsTreeView = QtWidgets.QTreeView(self.parent)
|
||||
|
||||
self._translate = QtCore.QCoreApplication.translate
|
||||
self.search_string = self._translate('SideDock', 'Search')
|
||||
self.search_book_string = self._translate('SideDock', 'Search entire book')
|
||||
self.case_sensitive_string = self._translate('SideDock', 'Match case')
|
||||
self.match_word_string = self._translate('SideDock', 'Match word')
|
||||
|
||||
self.create_widgets()
|
||||
|
||||
def create_widgets(self):
|
||||
self.searchThread.finished.connect(self.generate_search_result_model)
|
||||
|
||||
self.searchTimer.setSingleShot(True)
|
||||
self.searchTimer.timeout.connect(self.set_search_options)
|
||||
|
||||
self.searchLineEdit.textChanged.connect(
|
||||
lambda: self.searchLineEdit.setStyleSheet(
|
||||
QtWidgets.QLineEdit.styleSheet(self.parent)))
|
||||
self.searchLineEdit.textChanged.connect(
|
||||
lambda: self.searchTimer.start(500))
|
||||
self.searchBookButton.clicked.connect(
|
||||
lambda: self.searchTimer.start(100))
|
||||
self.caseSensitiveSearchButton.clicked.connect(
|
||||
lambda: self.searchTimer.start(100))
|
||||
self.matchWholeWordButton.clicked.connect(
|
||||
lambda: self.searchTimer.start(100))
|
||||
|
||||
self.searchLineEdit.setFocusPolicy(QtCore.Qt.StrongFocus)
|
||||
self.searchLineEdit.setClearButtonEnabled(True)
|
||||
self.searchLineEdit.setPlaceholderText(self.search_string)
|
||||
|
||||
self.searchBookButton.setIcon(
|
||||
self.parent.main_window.QImageFactory.get_image('view-readermode'))
|
||||
self.searchBookButton.setToolTip(self.search_book_string)
|
||||
self.searchBookButton.setCheckable(True)
|
||||
self.searchBookButton.setAutoRaise(True)
|
||||
self.searchBookButton.setIconSize(QtCore.QSize(20, 20))
|
||||
|
||||
self.caseSensitiveSearchButton.setIcon(
|
||||
self.parent.main_window.QImageFactory.get_image('search-case'))
|
||||
self.caseSensitiveSearchButton.setToolTip(self.case_sensitive_string)
|
||||
self.caseSensitiveSearchButton.setCheckable(True)
|
||||
self.caseSensitiveSearchButton.setAutoRaise(True)
|
||||
self.caseSensitiveSearchButton.setIconSize(QtCore.QSize(20, 20))
|
||||
|
||||
self.matchWholeWordButton.setIcon(
|
||||
self.parent.main_window.QImageFactory.get_image('search-word'))
|
||||
self.matchWholeWordButton.setToolTip(self.match_word_string)
|
||||
self.matchWholeWordButton.setCheckable(True)
|
||||
self.matchWholeWordButton.setAutoRaise(True)
|
||||
self.matchWholeWordButton.setIconSize(QtCore.QSize(20, 20))
|
||||
|
||||
self.searchOptionsLayout.setContentsMargins(0, 3, 0, 0)
|
||||
self.searchOptionsLayout.addWidget(self.searchLineEdit)
|
||||
self.searchOptionsLayout.addWidget(self.searchBookButton)
|
||||
self.searchOptionsLayout.addWidget(self.caseSensitiveSearchButton)
|
||||
self.searchOptionsLayout.addWidget(self.matchWholeWordButton)
|
||||
|
||||
self.searchResultsTreeView.setHeaderHidden(True)
|
||||
self.searchResultsTreeView.setEditTriggers(
|
||||
QtWidgets.QTreeView.NoEditTriggers)
|
||||
self.searchResultsTreeView.clicked.connect(
|
||||
self.navigate_to_search_result)
|
||||
|
||||
self.searchTabLayout.addLayout(self.searchOptionsLayout)
|
||||
self.searchTabLayout.addWidget(self.searchResultsTreeView)
|
||||
self.searchTabLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.searchTabWidget = QtWidgets.QWidget(self.parent)
|
||||
self.searchTabWidget.setLayout(self.searchTabLayout)
|
||||
|
||||
# Add widget to side dock
|
||||
self.parent.sideDockTabWidget.addTab(
|
||||
self.searchTabWidget, self.search_string)
|
||||
|
||||
def set_search_options(self):
|
||||
def generate_title_content_pair(required_chapters):
|
||||
title_content_list = []
|
||||
for i in self.parentTab.metadata['toc']:
|
||||
if i[2] in required_chapters:
|
||||
title_content_list.append(
|
||||
(i[1], self.parentTab.metadata['content'][i[2] - 1], i[2]))
|
||||
return title_content_list
|
||||
|
||||
# Select either the current chapter or all chapters
|
||||
# Function name is descriptive
|
||||
chapter_numbers = (self.parentTab.metadata['position']['current_chapter'],)
|
||||
if self.searchBookButton.isChecked():
|
||||
chapter_numbers = [i + 1 for i in range(len(self.parentTab.metadata['content']))]
|
||||
search_content = generate_title_content_pair(chapter_numbers)
|
||||
|
||||
self.searchThread.set_search_options(
|
||||
search_content,
|
||||
self.searchLineEdit.text(),
|
||||
self.caseSensitiveSearchButton.isChecked(),
|
||||
self.matchWholeWordButton.isChecked())
|
||||
self.searchThread.start()
|
||||
|
||||
def generate_search_result_model(self):
|
||||
self.parent.searchResultsModel.clear()
|
||||
search_results = self.searchThread.search_results
|
||||
for i in search_results:
|
||||
parentItem = QtGui.QStandardItem()
|
||||
parentItem.setData(True, QtCore.Qt.UserRole) # Is parent?
|
||||
parentItem.setData(i, QtCore.Qt.UserRole + 3) # Display text for label
|
||||
|
||||
for j in search_results[i]:
|
||||
childItem = QtGui.QStandardItem(parentItem)
|
||||
childItem.setData(False, QtCore.Qt.UserRole) # Is parent?
|
||||
childItem.setData(j[3], QtCore.Qt.UserRole + 1) # Chapter index
|
||||
childItem.setData(j[0], QtCore.Qt.UserRole + 2) # Cursor Position
|
||||
childItem.setData(j[1], QtCore.Qt.UserRole + 3) # Display text for label
|
||||
childItem.setData(j[2], QtCore.Qt.UserRole + 4) # Search term
|
||||
parentItem.appendRow(childItem)
|
||||
self.parent.searchResultsModel.appendRow(parentItem)
|
||||
|
||||
self.searchResultsTreeView.setModel(self.parent.searchResultsModel)
|
||||
self.searchResultsTreeView.expandToDepth(1)
|
||||
|
||||
# Reset stylesheet in case something is found
|
||||
if search_results:
|
||||
self.searchLineEdit.setStyleSheet(
|
||||
QtWidgets.QLineEdit.styleSheet(self.parent))
|
||||
|
||||
# Or set to Red in case nothing is found
|
||||
if not search_results and len(self.searchLineEdit.text()) > 2:
|
||||
self.searchLineEdit.setStyleSheet("QLineEdit {color: red;}")
|
||||
|
||||
# We'll be putting in labels instead of making a delegate
|
||||
# QLabels can understand RTF, and they also have the somewhat
|
||||
# distinct advantage of being a lot less work than a delegate
|
||||
|
||||
def generate_label(index):
|
||||
label_text = self.parent.searchResultsModel.data(index, QtCore.Qt.UserRole + 3)
|
||||
labelWidget = PliantLabelWidget(index, self.navigate_to_search_result)
|
||||
labelWidget.setText(label_text)
|
||||
self.searchResultsTreeView.setIndexWidget(index, labelWidget)
|
||||
|
||||
for parent_iter in range(self.parent.searchResultsModel.rowCount()):
|
||||
parentItem = self.parent.searchResultsModel.item(parent_iter)
|
||||
parentIndex = self.parent.searchResultsModel.index(parent_iter, 0)
|
||||
generate_label(parentIndex)
|
||||
|
||||
for child_iter in range(parentItem.rowCount()):
|
||||
childIndex = self.parent.searchResultsModel.index(child_iter, 0, parentIndex)
|
||||
generate_label(childIndex)
|
||||
|
||||
def navigate_to_search_result(self, index):
|
||||
if not index.isValid():
|
||||
return
|
||||
|
||||
is_parent = self.parent.searchResultsModel.data(index, QtCore.Qt.UserRole)
|
||||
if is_parent:
|
||||
return
|
||||
|
||||
chapter_number = self.parent.searchResultsModel.data(index, QtCore.Qt.UserRole + 1)
|
||||
cursor_position = self.parent.searchResultsModel.data(index, QtCore.Qt.UserRole + 2)
|
||||
search_term = self.parent.searchResultsModel.data(index, QtCore.Qt.UserRole + 4)
|
||||
|
||||
self.parentTab.set_content(chapter_number, True, True)
|
||||
if not self.parentTab.are_we_doing_images_only:
|
||||
self.parentTab.set_cursor_position(
|
||||
cursor_position, len(search_term))
|
||||
|
||||
|
||||
class PliantLabelWidget(QtWidgets.QLabel):
|
||||
# This is a hack to get clickable / editable appearance
|
||||
# search results in the tree view.
|
||||
|
||||
def __init__(self, index, navigate_to_search_result):
|
||||
super(PliantLabelWidget, self).__init__()
|
||||
self.index = index
|
||||
self.navigate_to_search_result = navigate_to_search_result
|
||||
|
||||
def mousePressEvent(self, QMouseEvent):
|
||||
self.navigate_to_search_result(self.index)
|
||||
QtWidgets.QLabel.mousePressEvent(self, QMouseEvent)
|
@@ -1,7 +1,5 @@
|
||||
#!usr/bin/env python3
|
||||
|
||||
# This file is a part of Lector, a Qt based ebook reader
|
||||
# Copyright (C) 2018 BasioMeusPuga
|
||||
# Copyright (C) 2017-2019 BasioMeusPuga
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -16,8 +14,15 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from PyQt5 import QtGui
|
||||
from resources import resources
|
||||
import logging
|
||||
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
|
||||
from lector import database
|
||||
from lector.settings import Settings
|
||||
from lector.resources import resources
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QImageFactory:
|
||||
@@ -30,3 +35,316 @@ class QImageFactory:
|
||||
|
||||
this_qicon = QtGui.QIcon(icon_path)
|
||||
return this_qicon
|
||||
|
||||
|
||||
# For nearly all cases below, code remains unchanged from its
|
||||
# state in the __main__ module. References to objects have been
|
||||
# made in the respective __init__ functions of the classes here
|
||||
class CoverLoadingAndCulling:
|
||||
def __init__(self, main_window):
|
||||
self.main_window = main_window
|
||||
self.lib_ref = self.main_window.lib_ref
|
||||
self.listView = self.main_window.listView
|
||||
self.database_path = self.main_window.database_path
|
||||
|
||||
def cull_covers(self, event=None):
|
||||
blank_pixmap = QtGui.QPixmap()
|
||||
blank_pixmap.load(':/images/blank.png') # Keep this. Removing it causes the
|
||||
# listView to go blank on a resize
|
||||
|
||||
all_indexes = set()
|
||||
for i in range(self.lib_ref.itemProxyModel.rowCount()):
|
||||
all_indexes.add(self.lib_ref.itemProxyModel.index(i, 0))
|
||||
|
||||
y_range = list(range(0, self.listView.viewport().height(), 100))
|
||||
y_range.extend((-20, self.listView.viewport().height() + 20))
|
||||
x_range = range(0, self.listView.viewport().width(), 80)
|
||||
|
||||
visible_indexes = set()
|
||||
for i in y_range:
|
||||
for j in x_range:
|
||||
this_index = self.listView.indexAt(QtCore.QPoint(j, i))
|
||||
visible_indexes.add(this_index)
|
||||
|
||||
invisible_indexes = all_indexes - visible_indexes
|
||||
for i in invisible_indexes:
|
||||
model_index = self.lib_ref.itemProxyModel.mapToSource(i)
|
||||
this_item = self.lib_ref.libraryModel.item(model_index.row())
|
||||
|
||||
if this_item:
|
||||
this_item.setIcon(QtGui.QIcon(blank_pixmap))
|
||||
this_item.setData(False, QtCore.Qt.UserRole + 8)
|
||||
|
||||
hash_index_dict = {}
|
||||
hash_list = []
|
||||
for i in visible_indexes:
|
||||
model_index = self.lib_ref.itemProxyModel.mapToSource(i)
|
||||
|
||||
book_hash = self.lib_ref.libraryModel.data(
|
||||
model_index, QtCore.Qt.UserRole + 6)
|
||||
cover_displayed = self.lib_ref.libraryModel.data(
|
||||
model_index, QtCore.Qt.UserRole + 8)
|
||||
|
||||
if book_hash and not cover_displayed:
|
||||
hash_list.append(book_hash)
|
||||
hash_index_dict[book_hash] = model_index
|
||||
|
||||
all_covers = database.DatabaseFunctions(
|
||||
self.database_path).fetch_covers_only(hash_list)
|
||||
|
||||
for i in all_covers:
|
||||
book_hash = i[0]
|
||||
cover = i[1]
|
||||
model_index = hash_index_dict[book_hash]
|
||||
|
||||
book_item = self.lib_ref.libraryModel.item(model_index.row())
|
||||
self.cover_loader(book_item, cover)
|
||||
|
||||
def load_all_covers(self):
|
||||
all_covers_db = database.DatabaseFunctions(
|
||||
self.database_path).fetch_data(
|
||||
('Hash', 'CoverImage',),
|
||||
'books',
|
||||
{'Hash': ''},
|
||||
'LIKE')
|
||||
|
||||
if not all_covers_db:
|
||||
return
|
||||
|
||||
all_covers = {
|
||||
i[0]: i[1] for i in all_covers_db}
|
||||
|
||||
for i in range(self.lib_ref.libraryModel.rowCount()):
|
||||
this_item = self.lib_ref.libraryModel.item(i, 0)
|
||||
|
||||
is_cover_already_displayed = this_item.data(QtCore.Qt.UserRole + 8)
|
||||
if is_cover_already_displayed:
|
||||
continue
|
||||
|
||||
book_hash = this_item.data(QtCore.Qt.UserRole + 6)
|
||||
cover = all_covers[book_hash]
|
||||
self.cover_loader(this_item, cover)
|
||||
|
||||
def cover_loader(self, item, cover):
|
||||
img_pixmap = QtGui.QPixmap()
|
||||
if cover:
|
||||
img_pixmap.loadFromData(cover)
|
||||
else:
|
||||
img_pixmap.load(':/images/NotFound.png')
|
||||
img_pixmap = img_pixmap.scaled(420, 600, QtCore.Qt.IgnoreAspectRatio)
|
||||
item.setIcon(QtGui.QIcon(img_pixmap))
|
||||
item.setData(True, QtCore.Qt.UserRole + 8)
|
||||
|
||||
|
||||
class ViewProfileModification:
|
||||
def __init__(self, main_window):
|
||||
self.main_window = main_window
|
||||
|
||||
self.listView = self.main_window.listView
|
||||
self.settings = self.main_window.settings
|
||||
self.bookToolBar = self.main_window.bookToolBar
|
||||
self.comic_profile = self.main_window.comic_profile
|
||||
self.tabWidget = self.main_window.tabWidget
|
||||
self.alignment_dict = self.main_window.alignment_dict
|
||||
|
||||
def get_color(self, signal_sender):
|
||||
def open_color_dialog(current_color):
|
||||
color_dialog = QtWidgets.QColorDialog()
|
||||
new_color = color_dialog.getColor(current_color)
|
||||
if new_color.isValid(): # Returned in case cancel is pressed
|
||||
return new_color
|
||||
else:
|
||||
return current_color
|
||||
|
||||
# Special cases that don't affect (comic)book display
|
||||
if signal_sender == 'libraryBackground':
|
||||
current_color = self.settings['listview_background']
|
||||
new_color = open_color_dialog(current_color)
|
||||
self.listView.setStyleSheet("QListView {{background-color: {0}}}".format(
|
||||
new_color.name()))
|
||||
self.settings['listview_background'] = new_color
|
||||
return
|
||||
|
||||
if signal_sender == 'dialogBackground':
|
||||
current_color = self.settings['dialog_background']
|
||||
new_color = open_color_dialog(current_color)
|
||||
self.settings['dialog_background'] = new_color
|
||||
return
|
||||
|
||||
profile_index = self.bookToolBar.profileBox.currentIndex()
|
||||
current_profile = self.bookToolBar.profileBox.itemData(
|
||||
profile_index, QtCore.Qt.UserRole)
|
||||
|
||||
# Retain current values on opening a new dialog
|
||||
if signal_sender == 'fgColor':
|
||||
current_color = current_profile['foreground']
|
||||
new_color = open_color_dialog(current_color)
|
||||
self.bookToolBar.colorBoxFG.setStyleSheet(
|
||||
'background-color: %s' % new_color.name())
|
||||
current_profile['foreground'] = new_color
|
||||
|
||||
elif signal_sender == 'bgColor':
|
||||
current_color = current_profile['background']
|
||||
new_color = open_color_dialog(current_color)
|
||||
self.bookToolBar.colorBoxBG.setStyleSheet(
|
||||
'background-color: %s' % new_color.name())
|
||||
current_profile['background'] = new_color
|
||||
|
||||
elif signal_sender == 'comicBGColor':
|
||||
current_color = self.comic_profile['background']
|
||||
new_color = open_color_dialog(current_color)
|
||||
self.bookToolBar.comicBGColor.setStyleSheet(
|
||||
'background-color: %s' % new_color.name())
|
||||
self.comic_profile['background'] = new_color
|
||||
|
||||
self.bookToolBar.profileBox.setItemData(
|
||||
profile_index, current_profile, QtCore.Qt.UserRole)
|
||||
self.format_contentView()
|
||||
|
||||
def modify_font(self, signal_sender):
|
||||
profile_index = self.bookToolBar.profileBox.currentIndex()
|
||||
current_profile = self.bookToolBar.profileBox.itemData(
|
||||
profile_index, QtCore.Qt.UserRole)
|
||||
|
||||
if signal_sender == 'fontBox':
|
||||
current_profile['font'] = self.bookToolBar.fontBox.currentFont().family()
|
||||
|
||||
if signal_sender == 'fontSizeBox':
|
||||
old_size = current_profile['font_size']
|
||||
new_size = self.bookToolBar.fontSizeBox.itemText(
|
||||
self.bookToolBar.fontSizeBox.currentIndex())
|
||||
if new_size.isdigit():
|
||||
current_profile['font_size'] = new_size
|
||||
else:
|
||||
current_profile['font_size'] = old_size
|
||||
|
||||
if signal_sender == 'lineSpacingUp' and current_profile['line_spacing'] < 200:
|
||||
current_profile['line_spacing'] += 5
|
||||
if signal_sender == 'lineSpacingDown' and current_profile['line_spacing'] > 90:
|
||||
current_profile['line_spacing'] -= 5
|
||||
|
||||
if signal_sender == 'paddingUp':
|
||||
current_profile['padding'] += 5
|
||||
if signal_sender == 'paddingDown':
|
||||
current_profile['padding'] -= 5
|
||||
|
||||
alignment_dict = {
|
||||
'alignLeft': 'left',
|
||||
'alignRight': 'right',
|
||||
'alignCenter': 'center',
|
||||
'alignJustify': 'justify'}
|
||||
if signal_sender in alignment_dict:
|
||||
current_profile['text_alignment'] = alignment_dict[signal_sender]
|
||||
|
||||
self.bookToolBar.profileBox.setItemData(
|
||||
profile_index, current_profile, QtCore.Qt.UserRole)
|
||||
self.format_contentView()
|
||||
|
||||
def modify_comic_view(self, signal_sender, key_pressed):
|
||||
comic_profile = self.main_window.comic_profile
|
||||
current_tab = self.tabWidget.widget(self.tabWidget.currentIndex())
|
||||
|
||||
self.bookToolBar.fitWidth.setChecked(False)
|
||||
self.bookToolBar.bestFit.setChecked(False)
|
||||
self.bookToolBar.originalSize.setChecked(False)
|
||||
|
||||
if signal_sender == 'zoomOut' or key_pressed == QtCore.Qt.Key_Minus:
|
||||
comic_profile['zoom_mode'] = 'manualZoom'
|
||||
comic_profile['padding'] += 50
|
||||
|
||||
# This prevents infinite zoom out
|
||||
if comic_profile['padding'] * 2 > current_tab.contentView.viewport().width():
|
||||
comic_profile['padding'] -= 50
|
||||
|
||||
if signal_sender == 'zoomIn' or key_pressed in (
|
||||
QtCore.Qt.Key_Plus, QtCore.Qt.Key_Equal):
|
||||
comic_profile['zoom_mode'] = 'manualZoom'
|
||||
comic_profile['padding'] -= 50
|
||||
|
||||
# This prevents infinite zoom in
|
||||
if comic_profile['padding'] < 0:
|
||||
comic_profile['padding'] = 0
|
||||
|
||||
if signal_sender == 'fitWidth' or key_pressed == QtCore.Qt.Key_W:
|
||||
comic_profile['zoom_mode'] = 'fitWidth'
|
||||
comic_profile['padding'] = 0
|
||||
self.bookToolBar.fitWidth.setChecked(True)
|
||||
|
||||
# Padding in the following cases is decided by
|
||||
# the image pixmap loaded by the widget
|
||||
if signal_sender == 'bestFit' or key_pressed == QtCore.Qt.Key_B:
|
||||
comic_profile['zoom_mode'] = 'bestFit'
|
||||
self.bookToolBar.bestFit.setChecked(True)
|
||||
|
||||
if signal_sender == 'originalSize' or key_pressed == QtCore.Qt.Key_O:
|
||||
comic_profile['zoom_mode'] = 'originalSize'
|
||||
self.bookToolBar.originalSize.setChecked(True)
|
||||
|
||||
self.format_contentView()
|
||||
|
||||
def format_contentView(self):
|
||||
current_tab = self.tabWidget.currentWidget()
|
||||
|
||||
try:
|
||||
current_metadata = current_tab.metadata
|
||||
except AttributeError:
|
||||
return
|
||||
|
||||
if current_metadata['images_only']:
|
||||
background = self.comic_profile['background']
|
||||
zoom_mode = self.comic_profile['zoom_mode']
|
||||
|
||||
if zoom_mode == 'fitWidth':
|
||||
self.bookToolBar.fitWidth.setChecked(True)
|
||||
if zoom_mode == 'bestFit':
|
||||
self.bookToolBar.bestFit.setChecked(True)
|
||||
if zoom_mode == 'originalSize':
|
||||
self.bookToolBar.originalSize.setChecked(True)
|
||||
|
||||
self.bookToolBar.comicBGColor.setStyleSheet(
|
||||
'background-color: %s' % background.name())
|
||||
|
||||
current_tab.format_view(
|
||||
None, None, None, background, None, None, None)
|
||||
|
||||
else:
|
||||
profile_index = self.bookToolBar.profileBox.currentIndex()
|
||||
current_profile = self.bookToolBar.profileBox.itemData(
|
||||
profile_index, QtCore.Qt.UserRole)
|
||||
|
||||
font = current_profile['font']
|
||||
foreground = current_profile['foreground']
|
||||
background = current_profile['background']
|
||||
padding = current_profile['padding']
|
||||
font_size = current_profile['font_size']
|
||||
line_spacing = current_profile['line_spacing']
|
||||
text_alignment = current_profile['text_alignment']
|
||||
|
||||
# Change toolbar widgets to match new settings
|
||||
self.bookToolBar.fontBox.blockSignals(True)
|
||||
self.bookToolBar.fontSizeBox.blockSignals(True)
|
||||
self.bookToolBar.fontBox.setCurrentText(font)
|
||||
current_index = self.bookToolBar.fontSizeBox.findText(
|
||||
str(font_size), QtCore.Qt.MatchExactly)
|
||||
self.bookToolBar.fontSizeBox.setCurrentIndex(current_index)
|
||||
self.bookToolBar.fontBox.blockSignals(False)
|
||||
self.bookToolBar.fontSizeBox.blockSignals(False)
|
||||
|
||||
self.alignment_dict[current_profile['text_alignment']].setChecked(True)
|
||||
|
||||
self.bookToolBar.colorBoxFG.setStyleSheet(
|
||||
'background-color: %s' % foreground.name())
|
||||
self.bookToolBar.colorBoxBG.setStyleSheet(
|
||||
'background-color: %s' % background.name())
|
||||
|
||||
current_tab.format_view(
|
||||
font, font_size, foreground,
|
||||
background, padding, line_spacing,
|
||||
text_alignment)
|
||||
|
||||
def reset_profile(self):
|
||||
current_profile_index = self.bookToolBar.profileBox.currentIndex()
|
||||
current_profile_default = Settings(self).default_profiles[current_profile_index]
|
||||
self.bookToolBar.profileBox.setItemData(
|
||||
current_profile_index, current_profile_default, QtCore.Qt.UserRole)
|
||||
self.format_contentView()
|
||||
|
@@ -1,7 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# This file is a part of Lector, a Qt based ebook reader
|
||||
# Copyright (C) 2017 BasioMeusPuga
|
||||
# Copyright (C) 2017-2019 BasioMeusPuga
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -18,39 +16,45 @@
|
||||
|
||||
import os
|
||||
import pickle
|
||||
import logging
|
||||
import pathlib
|
||||
|
||||
from PyQt5 import QtGui, QtCore
|
||||
|
||||
from lector import database
|
||||
from lector.models import TableProxyModel, ItemProxyModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Library:
|
||||
def __init__(self, parent):
|
||||
self.parent = parent
|
||||
self.view_model = None
|
||||
self.item_proxy_model = None
|
||||
self.table_proxy_model = None
|
||||
self.main_window = parent
|
||||
self.libraryModel = None
|
||||
self.itemProxyModel = None
|
||||
self.tableProxyModel = None
|
||||
self._translate = QtCore.QCoreApplication.translate
|
||||
|
||||
def generate_model(self, mode, parsed_books=None, is_database_ready=True):
|
||||
if mode == 'build':
|
||||
self.view_model = QtGui.QStandardItemModel()
|
||||
self.view_model.setColumnCount(10)
|
||||
self.libraryModel = QtGui.QStandardItemModel()
|
||||
self.libraryModel.setColumnCount(10)
|
||||
|
||||
books = database.DatabaseFunctions(
|
||||
self.parent.database_path).fetch_data(
|
||||
self.main_window.database_path).fetch_data(
|
||||
('Title', 'Author', 'Year', 'DateAdded', 'Path',
|
||||
'Position', 'ISBN', 'Tags', 'Hash', 'LastAccessed'),
|
||||
'Position', 'ISBN', 'Tags', 'Hash', 'LastAccessed',
|
||||
'Addition'),
|
||||
'books',
|
||||
{'Title': ''},
|
||||
'LIKE')
|
||||
|
||||
if not books:
|
||||
print('Database returned nothing')
|
||||
logger.warning('Database returned nothing')
|
||||
return
|
||||
|
||||
elif mode == 'addition':
|
||||
# Assumes self.view_model already exists and may be extended
|
||||
# Assumes self.libraryModel already exists and may be extended
|
||||
# Because any additional books have already been added to the
|
||||
# database using background threads
|
||||
|
||||
@@ -63,7 +67,7 @@ class Library:
|
||||
|
||||
books.append([
|
||||
i[1]['title'], i[1]['author'], i[1]['year'], current_qdatetime,
|
||||
i[1]['path'], None, i[1]['isbn'], _tags, i[0], None])
|
||||
i[1]['path'], None, i[1]['isbn'], _tags, i[0], None, i[1]['addition_mode']])
|
||||
|
||||
else:
|
||||
return
|
||||
@@ -75,7 +79,11 @@ class Library:
|
||||
author = i[1]
|
||||
year = i[2]
|
||||
path = i[4]
|
||||
addition_mode = i[10]
|
||||
|
||||
last_accessed = i[9]
|
||||
if last_accessed and not isinstance(last_accessed, QtCore.QDateTime):
|
||||
last_accessed = pickle.loads(last_accessed)
|
||||
|
||||
tags = i[7]
|
||||
if isinstance(tags, list): # When files are added for the first time
|
||||
@@ -93,16 +101,12 @@ class Library:
|
||||
position = i[5]
|
||||
if position:
|
||||
position = pickle.loads(position)
|
||||
if position['is_read']:
|
||||
position_perc = 100
|
||||
else:
|
||||
try:
|
||||
position_perc = (
|
||||
position['current_chapter'] * 100 / position['total_chapters'])
|
||||
except KeyError:
|
||||
position_perc = None
|
||||
position_perc = generate_position_percentage(position)
|
||||
|
||||
file_exists = os.path.exists(path)
|
||||
try:
|
||||
file_exists = os.path.exists(path)
|
||||
except UnicodeEncodeError:
|
||||
print('Library: Unicode encoding error')
|
||||
|
||||
all_metadata = {
|
||||
'title': title,
|
||||
@@ -115,9 +119,12 @@ class Library:
|
||||
'tags': tags,
|
||||
'hash': i[8],
|
||||
'last_accessed': last_accessed,
|
||||
'addition_mode': addition_mode,
|
||||
'file_exists': file_exists}
|
||||
|
||||
tooltip_string = title + '\nAuthor: ' + author + '\nYear: ' + str(year)
|
||||
author_string = self._translate('Library', 'Author')
|
||||
year_string = self._translate('Library', 'Year')
|
||||
tooltip_string = f'{title} \n{author_string}: {author} \n{year_string}: {str(year)}'
|
||||
|
||||
# Additional data can be set using an incrementing
|
||||
# QtCore.Qt.UserRole
|
||||
@@ -134,6 +141,7 @@ class Library:
|
||||
item.setToolTip(tooltip_string)
|
||||
|
||||
# Just keep the following order. It's way too much trouble otherwise
|
||||
# User roles have to be correlated to sorting order below
|
||||
item.setData(title, QtCore.Qt.UserRole)
|
||||
item.setData(author, QtCore.Qt.UserRole + 1)
|
||||
item.setData(year, QtCore.Qt.UserRole + 2)
|
||||
@@ -145,53 +153,61 @@ class Library:
|
||||
item.setData(False, QtCore.Qt.UserRole + 8) # Is the cover being displayed?
|
||||
item.setData(date_added, QtCore.Qt.UserRole + 9)
|
||||
item.setData(last_accessed, QtCore.Qt.UserRole + 12)
|
||||
item.setData(path, QtCore.Qt.UserRole + 13)
|
||||
item.setIcon(QtGui.QIcon(img_pixmap))
|
||||
|
||||
self.view_model.appendRow(item)
|
||||
self.libraryModel.appendRow(item)
|
||||
|
||||
# The is_database_ready boolean is required when a new thread sends
|
||||
# books here for model generation.
|
||||
if not self.parent.settings['perform_culling'] and is_database_ready:
|
||||
self.parent.load_all_covers()
|
||||
if not self.main_window.settings['perform_culling'] and is_database_ready:
|
||||
self.main_window.cover_functions.load_all_covers()
|
||||
|
||||
def generate_proxymodels(self):
|
||||
self.item_proxy_model = ItemProxyModel()
|
||||
self.item_proxy_model.setSourceModel(self.view_model)
|
||||
self.item_proxy_model.setSortCaseSensitivity(False)
|
||||
self.itemProxyModel = ItemProxyModel()
|
||||
self.itemProxyModel.setSourceModel(self.libraryModel)
|
||||
self.itemProxyModel.setSortCaseSensitivity(False)
|
||||
s = QtCore.QSize(160, 250) # Set icon sizing here
|
||||
self.parent.listView.setIconSize(s)
|
||||
self.parent.listView.setModel(self.item_proxy_model)
|
||||
self.main_window.listView.setIconSize(s)
|
||||
self.main_window.listView.setModel(self.itemProxyModel)
|
||||
|
||||
self.table_proxy_model = TableProxyModel(self.parent.temp_dir.path())
|
||||
self.table_proxy_model.setSourceModel(self.view_model)
|
||||
self.table_proxy_model.setSortCaseSensitivity(False)
|
||||
self.parent.tableView.setModel(self.table_proxy_model)
|
||||
self.tableProxyModel = TableProxyModel(
|
||||
self.main_window.temp_dir.path(),
|
||||
self.main_window.tableView.horizontalHeader(),
|
||||
self.main_window.settings['consider_read_at'])
|
||||
self.tableProxyModel.setSourceModel(self.libraryModel)
|
||||
self.tableProxyModel.setSortCaseSensitivity(False)
|
||||
self.main_window.tableView.setModel(self.tableProxyModel)
|
||||
|
||||
self.update_proxymodels()
|
||||
|
||||
def update_proxymodels(self):
|
||||
# Table proxy model
|
||||
self.table_proxy_model.invalidateFilter()
|
||||
self.table_proxy_model.setFilterParams(
|
||||
self.parent.libraryToolBar.searchBar.text(),
|
||||
self.parent.active_library_filters,
|
||||
self.tableProxyModel.invalidateFilter()
|
||||
self.tableProxyModel.setFilterParams(
|
||||
self.main_window.libraryToolBar.searchBar.text(),
|
||||
self.main_window.active_library_filters,
|
||||
0) # This doesn't need to know the sorting box position
|
||||
self.table_proxy_model.setFilterFixedString(
|
||||
self.parent.libraryToolBar.searchBar.text())
|
||||
self.tableProxyModel.setFilterFixedString(
|
||||
self.main_window.libraryToolBar.searchBar.text())
|
||||
# ^^^ This isn't needed, but it forces a model update every time the
|
||||
# text in the line edit changes. So I guess it is needed.
|
||||
self.tableProxyModel.sort_table_columns(
|
||||
self.main_window.tableView.horizontalHeader().sortIndicatorSection())
|
||||
self.tableProxyModel.sort_table_columns()
|
||||
|
||||
# Item proxy model
|
||||
self.item_proxy_model.invalidateFilter()
|
||||
self.item_proxy_model.setFilterParams(
|
||||
self.parent.libraryToolBar.searchBar.text(),
|
||||
self.parent.active_library_filters,
|
||||
self.parent.libraryToolBar.sortingBox.currentIndex())
|
||||
self.item_proxy_model.setFilterFixedString(
|
||||
self.parent.libraryToolBar.searchBar.text())
|
||||
self.itemProxyModel.invalidateFilter()
|
||||
self.itemProxyModel.setFilterParams(
|
||||
self.main_window.libraryToolBar.searchBar.text(),
|
||||
self.main_window.active_library_filters,
|
||||
self.main_window.libraryToolBar.sortingBox.currentIndex())
|
||||
self.itemProxyModel.setFilterFixedString(
|
||||
self.main_window.libraryToolBar.searchBar.text())
|
||||
|
||||
self.parent.statusMessage.setText(
|
||||
str(self.item_proxy_model.rowCount()) + ' books')
|
||||
self.main_window.statusMessage.setText(
|
||||
str(self.itemProxyModel.rowCount()) +
|
||||
self._translate('Library', ' books'))
|
||||
|
||||
# TODO
|
||||
# Allow sorting by type
|
||||
@@ -205,33 +221,46 @@ class Library:
|
||||
1: 1,
|
||||
2: 2,
|
||||
3: 9,
|
||||
4: 12}
|
||||
4: 12,
|
||||
5: 7}
|
||||
|
||||
# Sorting according to roles and the drop down in the library toolbar
|
||||
self.item_proxy_model.setSortRole(
|
||||
QtCore.Qt.UserRole + sort_roles[self.parent.libraryToolBar.sortingBox.currentIndex()])
|
||||
self.itemProxyModel.setSortRole(
|
||||
QtCore.Qt.UserRole +
|
||||
sort_roles[self.main_window.libraryToolBar.sortingBox.currentIndex()])
|
||||
|
||||
# This can be expanded to other fields by appending to the list
|
||||
sort_order = QtCore.Qt.AscendingOrder
|
||||
if self.parent.libraryToolBar.sortingBox.currentIndex() in [3, 4]:
|
||||
if self.main_window.libraryToolBar.sortingBox.currentIndex() in [3, 4, 5]:
|
||||
sort_order = QtCore.Qt.DescendingOrder
|
||||
|
||||
self.item_proxy_model.sort(0, sort_order)
|
||||
self.parent.start_culling_timer()
|
||||
self.itemProxyModel.sort(0, sort_order)
|
||||
self.main_window.start_culling_timer()
|
||||
|
||||
def generate_library_tags(self):
|
||||
db_library_directories = database.DatabaseFunctions(
|
||||
self.parent.database_path).fetch_data(
|
||||
self.main_window.database_path).fetch_data(
|
||||
('Path', 'Name', 'Tags'),
|
||||
'directories', # This checks the directories table NOT the book one
|
||||
{'Path': ''},
|
||||
'LIKE')
|
||||
|
||||
if not db_library_directories: # Empty database / table
|
||||
return
|
||||
if db_library_directories: # Empty database / table
|
||||
library_directories = {
|
||||
i[0]: (i[1], i[2]) for i in db_library_directories}
|
||||
|
||||
library_directories = {
|
||||
i[0]: (i[1], i[2]) for i in db_library_directories}
|
||||
else:
|
||||
db_library_directories = database.DatabaseFunctions(
|
||||
self.main_window.database_path).fetch_data(
|
||||
('Path',),
|
||||
'books', # THIS CHECKS THE BOOKS TABLE
|
||||
{'Path': ''},
|
||||
'LIKE')
|
||||
|
||||
library_directories = None
|
||||
if db_library_directories:
|
||||
library_directories = {
|
||||
i[0]: (None, None) for i in db_library_directories}
|
||||
|
||||
def get_tags(all_metadata):
|
||||
path = os.path.dirname(all_metadata['path'])
|
||||
@@ -243,7 +272,7 @@ class Library:
|
||||
if directory_name:
|
||||
directory_name = directory_name.lower()
|
||||
else:
|
||||
directory_name = path.rsplit('/')[-1].lower()
|
||||
directory_name = i.rsplit(os.sep)[-1].lower()
|
||||
|
||||
directory_tags = library_directories[i][1]
|
||||
if directory_tags:
|
||||
@@ -251,11 +280,15 @@ class Library:
|
||||
|
||||
return directory_name, directory_tags
|
||||
|
||||
return 'manually added', None
|
||||
# A file is assigned a 'manually added' tag in case it isn't
|
||||
# in any designated library directory
|
||||
added_string = self._translate('Library', 'manually added')
|
||||
return added_string.lower(), None
|
||||
|
||||
# Generate tags for the QStandardItemModel
|
||||
for i in range(self.view_model.rowCount()):
|
||||
this_item = self.view_model.item(i, 0)
|
||||
# This isn't triggered for an empty view model
|
||||
for i in range(self.libraryModel.rowCount()):
|
||||
this_item = self.libraryModel.item(i, 0)
|
||||
all_metadata = this_item.data(QtCore.Qt.UserRole + 3)
|
||||
directory_name, directory_tags = get_tags(all_metadata)
|
||||
|
||||
@@ -267,30 +300,51 @@ class Library:
|
||||
# All files in unselected directories will have to be removed
|
||||
# from both of the models
|
||||
# They will also have to be deleted from the library
|
||||
valid_paths = set(valid_paths)
|
||||
invalid_paths = []
|
||||
deletable_persistent_indexes = []
|
||||
|
||||
for i in range(self.libraryModel.rowCount()):
|
||||
item = self.libraryModel.item(i)
|
||||
|
||||
# Get all paths
|
||||
all_paths = set()
|
||||
for i in range(self.view_model.rowCount()):
|
||||
item = self.view_model.item(i, 0)
|
||||
item_metadata = item.data(QtCore.Qt.UserRole + 3)
|
||||
book_path = item_metadata['path']
|
||||
all_paths.add(book_path)
|
||||
try:
|
||||
addition_mode = item_metadata['addition_mode']
|
||||
except KeyError:
|
||||
addition_mode = 'automatic'
|
||||
logger.error('Libary: Error setting addition mode for prune')
|
||||
|
||||
invalid_paths = all_paths - valid_paths
|
||||
if (book_path not in valid_paths and
|
||||
(addition_mode != 'manual' or addition_mode is None)):
|
||||
|
||||
deletable_persistent_indexes = []
|
||||
for i in range(self.view_model.rowCount()):
|
||||
item = self.view_model.item(i)
|
||||
path = item.data(QtCore.Qt.UserRole + 3)['path']
|
||||
if path in invalid_paths:
|
||||
invalid_paths.append(book_path)
|
||||
deletable_persistent_indexes.append(
|
||||
QtCore.QPersistentModelIndex(item.index()))
|
||||
|
||||
if deletable_persistent_indexes:
|
||||
for i in deletable_persistent_indexes:
|
||||
self.view_model.removeRow(i.row())
|
||||
self.libraryModel.removeRow(i.row())
|
||||
|
||||
# Remove invalid paths from the database as well
|
||||
database.DatabaseFunctions(
|
||||
self.parent.database_path).delete_from_database('Path', invalid_paths)
|
||||
self.main_window.database_path).delete_from_database('Path', invalid_paths)
|
||||
|
||||
|
||||
def generate_position_percentage(position):
|
||||
if not position:
|
||||
return None
|
||||
|
||||
if position['is_read']:
|
||||
position_perc = 1
|
||||
else:
|
||||
try:
|
||||
position_perc = (
|
||||
position['current_block'] / position['total_blocks'])
|
||||
except (KeyError, ZeroDivisionError):
|
||||
try:
|
||||
position_perc = (
|
||||
position['current_chapter'] / position['total_chapters'])
|
||||
except KeyError:
|
||||
position_perc = None
|
||||
|
||||
return position_perc
|
||||
|
58
lector/logger.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# This file is a part of Lector, a Qt based ebook reader
|
||||
# Copyright (C) 2017-2019 BasioMeusPuga
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
VERSION = '0.5.1'
|
||||
|
||||
import os
|
||||
import logging
|
||||
|
||||
from PyQt5 import QtCore
|
||||
|
||||
location_prefix = os.path.join(
|
||||
QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.AppDataLocation),
|
||||
'Lector')
|
||||
logger_filename = os.path.join(location_prefix, 'Lector.log')
|
||||
|
||||
|
||||
def init_logging(cli_arguments):
|
||||
# This needs a separate 'Lector' in the os.path.join because
|
||||
# application name isn't explicitly set in this module
|
||||
|
||||
os.makedirs(location_prefix, exist_ok=True)
|
||||
|
||||
log_level = 30 # Warning and above
|
||||
# Set log level according to command line arguments
|
||||
try:
|
||||
if cli_arguments[1] == 'debug':
|
||||
log_level = 10 # Debug and above
|
||||
print('Debug logging enabled')
|
||||
try:
|
||||
os.remove(logger_filename) # Remove old log for clarity
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
# Create logging object
|
||||
logging.basicConfig(
|
||||
filename=logger_filename,
|
||||
filemode='a',
|
||||
format='%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s',
|
||||
datefmt='%Y/%m/%d %H:%M:%S',
|
||||
level=log_level)
|
||||
logging.addLevelName(60, 'HAMMERTIME') ## Messages that MUST be logged
|
||||
|
||||
return logging.getLogger('lector.main')
|
@@ -1,7 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# This file is a part of Lector, a Qt based ebook reader
|
||||
# Copyright (C) 2017 BasioMeusPuga
|
||||
# Copyright (C) 2017-2019 BasioMeusPuga
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -16,18 +14,22 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import logging
|
||||
|
||||
from PyQt5 import QtWidgets, QtCore, QtGui
|
||||
|
||||
from lector import database
|
||||
|
||||
from resources import metadata
|
||||
from lector.widgets import PliantQGraphicsScene
|
||||
from lector.resources import metadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetadataUI(QtWidgets.QDialog, metadata.Ui_Dialog):
|
||||
def __init__(self, parent):
|
||||
super(MetadataUI, self).__init__()
|
||||
self.setupUi(self)
|
||||
self._translate = QtCore.QCoreApplication.translate
|
||||
|
||||
self.setWindowFlags(
|
||||
QtCore.Qt.Popup |
|
||||
@@ -38,8 +40,12 @@ class MetadataUI(QtWidgets.QDialog, metadata.Ui_Dialog):
|
||||
radius = 15
|
||||
path = QtGui.QPainterPath()
|
||||
path.addRoundedRect(QtCore.QRectF(self.rect()), radius, radius)
|
||||
mask = QtGui.QRegion(path.toFillPolygon().toPolygon())
|
||||
self.setMask(mask)
|
||||
|
||||
try:
|
||||
mask = QtGui.QRegion(path.toFillPolygon().toPolygon())
|
||||
self.setMask(mask)
|
||||
except TypeError: # Required for older versions of Qt
|
||||
pass
|
||||
|
||||
self.parent = parent
|
||||
self.database_path = self.parent.database_path
|
||||
@@ -85,8 +91,8 @@ class MetadataUI(QtWidgets.QDialog, metadata.Ui_Dialog):
|
||||
graphics_scene.addPixmap(image_pixmap)
|
||||
self.coverView.setScene(graphics_scene)
|
||||
|
||||
def ok_pressed(self, event):
|
||||
book_item = self.parent.lib_ref.view_model.item(self.book_index.row())
|
||||
def ok_pressed(self, event=None):
|
||||
book_item = self.parent.lib_ref.libraryModel.item(self.book_index.row())
|
||||
|
||||
title = self.titleLine.text()
|
||||
author = self.authorLine.text()
|
||||
@@ -97,7 +103,9 @@ class MetadataUI(QtWidgets.QDialog, metadata.Ui_Dialog):
|
||||
except ValueError:
|
||||
year = self.book_year
|
||||
|
||||
tooltip_string = title + '\nAuthor: ' + author + '\nYear: ' + str(year)
|
||||
author_string = self._translate('MetadataUI', 'Author')
|
||||
year_string = self._translate('MetadataUI', 'Year')
|
||||
tooltip_string = f'{title} \n{author_string}: {author} \n{year_string}: {str(year)}'
|
||||
|
||||
book_item.setData(title, QtCore.Qt.UserRole)
|
||||
book_item.setData(author, QtCore.Qt.UserRole + 1)
|
||||
@@ -114,7 +122,7 @@ class MetadataUI(QtWidgets.QDialog, metadata.Ui_Dialog):
|
||||
|
||||
if self.cover_for_database:
|
||||
database_dict['CoverImage'] = self.cover_for_database
|
||||
self.parent.cover_loader(
|
||||
self.parent.cover_functions.cover_loader(
|
||||
book_item, self.cover_for_database)
|
||||
|
||||
self.parent.lib_ref.update_proxymodels()
|
||||
@@ -123,7 +131,7 @@ class MetadataUI(QtWidgets.QDialog, metadata.Ui_Dialog):
|
||||
database.DatabaseFunctions(self.database_path).modify_metadata(
|
||||
database_dict, book_hash)
|
||||
|
||||
def cancel_pressed(self, event):
|
||||
def cancel_pressed(self, event=None):
|
||||
self.hide()
|
||||
|
||||
def generate_display_position(self, mouse_cursor_position):
|
||||
@@ -146,7 +154,8 @@ class MetadataUI(QtWidgets.QDialog, metadata.Ui_Dialog):
|
||||
background = self.parent.settings['dialog_background']
|
||||
else:
|
||||
self.previous_position = self.pos()
|
||||
background = self.parent.get_color()
|
||||
self.parent.get_color()
|
||||
background = self.parent.settings['dialog_background']
|
||||
|
||||
self.setStyleSheet(
|
||||
"QDialog {{background-color: {0}}}".format(background.name()))
|
||||
|
124
lector/models.py
@@ -1,7 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# This file is a part of Lector, a Qt based ebook reader
|
||||
# Copyright (C) 2017 BasioMeusPuga
|
||||
# Copyright (C) 2017-2019 BasioMeusPuga
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -16,34 +14,32 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import pickle
|
||||
import logging
|
||||
import pathlib
|
||||
|
||||
from PyQt5 import QtCore, QtWidgets
|
||||
from resources import pie_chart
|
||||
from lector.resources import pie_chart
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BookmarkProxyModel(QtCore.QSortFilterProxyModel):
|
||||
def __init__(self, parent=None):
|
||||
super(BookmarkProxyModel, self).__init__(parent)
|
||||
self.parent = parent
|
||||
self.filter_string = None
|
||||
self.parentTab = self.parent.parent
|
||||
self.filter_text = None
|
||||
|
||||
def setFilterParams(self, filter_text):
|
||||
self.filter_text = filter_text
|
||||
|
||||
def filterAcceptsRow(self, row, parent):
|
||||
# TODO
|
||||
# Connect this to the search bar
|
||||
return True
|
||||
|
||||
def setData(self, index, value, role):
|
||||
if role == QtCore.Qt.EditRole:
|
||||
source_index = self.mapToSource(index)
|
||||
identifier = self.sourceModel().data(source_index, QtCore.Qt.UserRole + 2)
|
||||
|
||||
self.sourceModel().setData(source_index, value, QtCore.Qt.DisplayRole)
|
||||
self.parent.metadata['bookmarks'][identifier]['description'] = value
|
||||
self.parentTab.metadata['bookmarks'][identifier]['description'] = value
|
||||
|
||||
return True
|
||||
|
||||
@@ -66,10 +62,21 @@ class ItemProxyModel(QtCore.QSortFilterProxyModel):
|
||||
|
||||
|
||||
class TableProxyModel(QtCore.QSortFilterProxyModel):
|
||||
def __init__(self, temp_dir, parent=None):
|
||||
def __init__(self, temp_dir, tableViewHeader, consider_read_at, parent=None):
|
||||
super(TableProxyModel, self).__init__(parent)
|
||||
self.tableViewHeader = tableViewHeader
|
||||
self.consider_read_at = consider_read_at
|
||||
self._translate = QtCore.QCoreApplication.translate
|
||||
|
||||
title_string = self._translate('TableProxyModel', 'Title')
|
||||
author_string = self._translate('TableProxyModel', 'Author')
|
||||
year_string = self._translate('TableProxyModel', 'Year')
|
||||
lastread_string = self._translate('TableProxyModel', 'Last Read')
|
||||
tags_string = self._translate('TableProxyModel', 'Tags')
|
||||
self.header_data = [
|
||||
None, 'Title', 'Author', 'Year', 'Last Read', '%', 'Tags']
|
||||
None, title_string, author_string,
|
||||
year_string, lastread_string, '%', tags_string]
|
||||
|
||||
self.temp_dir = temp_dir
|
||||
self.filter_text = None
|
||||
self.active_library_filters = None
|
||||
@@ -88,7 +95,13 @@ class TableProxyModel(QtCore.QSortFilterProxyModel):
|
||||
|
||||
def headerData(self, column, orientation, role):
|
||||
if role == QtCore.Qt.DisplayRole:
|
||||
return self.header_data[column]
|
||||
try:
|
||||
return self.header_data[column]
|
||||
except IndexError:
|
||||
logger.error(
|
||||
'Table proxy model: Can\'t find header for column' + str(column))
|
||||
# The column will be called IndexError. Not a typo.
|
||||
return 'IndexError'
|
||||
|
||||
def flags(self, index):
|
||||
# Tag editing will take place by way of a right click menu
|
||||
@@ -108,46 +121,27 @@ class TableProxyModel(QtCore.QSortFilterProxyModel):
|
||||
return_pixmap = None
|
||||
|
||||
file_exists = item.data(QtCore.Qt.UserRole + 5)
|
||||
metadata = item.data(QtCore.Qt.UserRole + 3)
|
||||
position = metadata['position']
|
||||
if position:
|
||||
is_read = position['is_read']
|
||||
position_percent = item.data(QtCore.Qt.UserRole + 7)
|
||||
|
||||
if not file_exists:
|
||||
return pie_chart.pixmapper(
|
||||
-1, None, None, QtCore.Qt.SizeHintRole + 10)
|
||||
|
||||
if position:
|
||||
if is_read:
|
||||
current_chapter = total_chapters = 100
|
||||
else:
|
||||
try:
|
||||
current_chapter = position['current_chapter']
|
||||
total_chapters = position['total_chapters']
|
||||
|
||||
# TODO
|
||||
# See if there's any rationale for this
|
||||
if current_chapter == 1:
|
||||
raise KeyError
|
||||
except KeyError:
|
||||
return
|
||||
|
||||
if position_percent:
|
||||
return_pixmap = pie_chart.pixmapper(
|
||||
current_chapter, total_chapters, self.temp_dir,
|
||||
position_percent, self.temp_dir,
|
||||
self.consider_read_at,
|
||||
QtCore.Qt.SizeHintRole + 10)
|
||||
|
||||
return return_pixmap
|
||||
|
||||
elif role == QtCore.Qt.DisplayRole or role == QtCore.Qt.EditRole:
|
||||
if index.column() in (0, 5): # Cover and Status
|
||||
if index.column() in (0, 5): # Cover and Status
|
||||
return QtCore.QVariant()
|
||||
|
||||
if index.column() == 4:
|
||||
last_accessed_time = item.data(self.role_dictionary[index.column()])
|
||||
if last_accessed_time:
|
||||
last_accessed = last_accessed_time
|
||||
if not isinstance(last_accessed_time, QtCore.QDateTime):
|
||||
last_accessed = pickle.loads(last_accessed_time)
|
||||
last_accessed = item.data(self.role_dictionary[index.column()])
|
||||
if last_accessed:
|
||||
right_now = QtCore.QDateTime().currentDateTime()
|
||||
time_diff = last_accessed.msecsTo(right_now)
|
||||
return self.time_convert(time_diff // 1000)
|
||||
@@ -164,10 +158,13 @@ class TableProxyModel(QtCore.QSortFilterProxyModel):
|
||||
output = self.common_functions.filterAcceptsRow(row, parent)
|
||||
return output
|
||||
|
||||
def sort_table_columns(self, column):
|
||||
sorting_order = self.sender().sortIndicatorOrder()
|
||||
def sort_table_columns(self, column=None):
|
||||
column = self.tableViewHeader.sortIndicatorSection()
|
||||
sorting_order = self.tableViewHeader.sortIndicatorOrder()
|
||||
|
||||
self.sort(0, sorting_order)
|
||||
self.setSortRole(self.role_dictionary[column])
|
||||
if column != 0:
|
||||
self.setSortRole(self.role_dictionary[column])
|
||||
|
||||
def time_convert(self, seconds):
|
||||
seconds = int(seconds)
|
||||
@@ -201,14 +198,20 @@ class ProxyModelsCommonFunctions:
|
||||
title = model.data(this_index, QtCore.Qt.UserRole)
|
||||
author = model.data(this_index, QtCore.Qt.UserRole + 1)
|
||||
tags = model.data(this_index, QtCore.Qt.UserRole + 4)
|
||||
progress = model.data(this_index, QtCore.Qt.UserRole + 7)
|
||||
directory_name = model.data(this_index, QtCore.Qt.UserRole + 10)
|
||||
directory_tags = model.data(this_index, QtCore.Qt.UserRole + 11)
|
||||
last_accessed = model.data(this_index, QtCore.Qt.UserRole + 12)
|
||||
file_path = model.data(this_index, QtCore.Qt.UserRole + 13)
|
||||
|
||||
# Hide untouched files when sorting by last accessed
|
||||
if self.parent_model.sorting_box_position == 4 and not last_accessed:
|
||||
return False
|
||||
|
||||
# Hide untouched files when sorting by progress
|
||||
if self.parent_model.sorting_box_position == 5 and not progress:
|
||||
return False
|
||||
|
||||
if self.parent_model.active_library_filters:
|
||||
if directory_name not in self.parent_model.active_library_filters:
|
||||
return False
|
||||
@@ -220,7 +223,9 @@ class ProxyModelsCommonFunctions:
|
||||
else:
|
||||
valid_data = [
|
||||
i.lower() for i in (
|
||||
title, author, tags, directory_name, directory_tags) if i is not None]
|
||||
title, author, tags, directory_name,
|
||||
directory_tags, file_path)
|
||||
if i is not None]
|
||||
for i in valid_data:
|
||||
if self.parent_model.filter_text.lower() in i:
|
||||
return True
|
||||
@@ -338,32 +343,3 @@ class MostExcellentFileSystemModel(QtWidgets.QFileSystemModel):
|
||||
|
||||
for i in deletable:
|
||||
del self.tag_data[i]
|
||||
|
||||
|
||||
# TODO
|
||||
# Unbork this
|
||||
class FileSystemProxyModel(QtCore.QSortFilterProxyModel):
|
||||
def __init__(self, parent=None):
|
||||
super(FileSystemProxyModel, self).__init__(parent)
|
||||
|
||||
def filterAcceptsRow(self, row_num, parent):
|
||||
model = self.sourceModel()
|
||||
filter_out = [
|
||||
'boot', 'dev', 'etc', 'lost+found', 'opt', 'pdb',
|
||||
'proc', 'root', 'run', 'srv', 'sys', 'tmp', 'twonky',
|
||||
'usr', 'var', 'bin', 'kdeinit5__0', 'lib', 'lib64', 'sbin']
|
||||
|
||||
name_index = model.index(row_num, 0)
|
||||
valid_data = model.data(name_index)
|
||||
|
||||
print(valid_data)
|
||||
|
||||
return True
|
||||
|
||||
try:
|
||||
if valid_data in filter_out:
|
||||
return False
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
80
lector/parsers/comicbooks.py
Normal file
@@ -0,0 +1,80 @@
|
||||
# This file is a part of Lector, a Qt based ebook reader
|
||||
# Copyright (C) 2017-19 BasioMeusPuga
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# TODO
|
||||
# Account for files with passwords
|
||||
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import zipfile
|
||||
import collections
|
||||
|
||||
from lector.rarfile import rarfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ParseCOMIC:
|
||||
def __init__(self, filename, *args):
|
||||
self.filename = filename
|
||||
self.book = None
|
||||
self.image_list = None
|
||||
self.book_extension = os.path.splitext(self.filename)
|
||||
|
||||
def read_book(self):
|
||||
if self.book_extension[1] == '.cbz':
|
||||
self.book = zipfile.ZipFile(
|
||||
self.filename, mode='r', allowZip64=True)
|
||||
self.image_list = [
|
||||
i.filename for i in self.book.infolist()
|
||||
if not i.is_dir() and is_image(i.filename)]
|
||||
|
||||
elif self.book_extension[1] == '.cbr':
|
||||
self.book = rarfile.RarFile(self.filename)
|
||||
self.image_list = [
|
||||
i.filename for i in self.book.infolist()
|
||||
if not i.isdir() and is_image(i.filename)]
|
||||
|
||||
self.image_list.sort()
|
||||
|
||||
def generate_metadata(self):
|
||||
title = os.path.basename(self.book_extension[0]).strip(' ')
|
||||
author = '<Unknown>'
|
||||
isbn = None
|
||||
tags = []
|
||||
cover = self.book.read(self.image_list[0])
|
||||
|
||||
creation_time = time.ctime(os.path.getctime(self.filename))
|
||||
year = creation_time.split()[-1]
|
||||
|
||||
Metadata = collections.namedtuple(
|
||||
'Metadata', ['title', 'author', 'year', 'isbn', 'tags', 'cover'])
|
||||
return Metadata(title, author, year, isbn, tags, cover)
|
||||
|
||||
def generate_content(self):
|
||||
image_number = len(self.image_list)
|
||||
toc = [(1, f'Page {i + 1}', i + 1) for i in range(image_number)]
|
||||
|
||||
# Return toc, content, images_only
|
||||
return toc, self.image_list, True
|
||||
|
||||
def is_image(filename):
|
||||
valid_image_extensions = ['.png', '.jpg', '.bmp']
|
||||
if os.path.splitext(filename)[1].lower() in valid_image_extensions:
|
||||
return True
|
||||
else:
|
||||
return False
|
@@ -1,7 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# This file is a part of Lector, a Qt based ebook reader
|
||||
# Copyright (C) 2017 BasioMeusPuga
|
||||
# Copyright (C) 2017-2019 BasioMeusPuga
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -16,51 +14,43 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# TODO
|
||||
# Maybe also include book description
|
||||
|
||||
import os
|
||||
import zipfile
|
||||
import logging
|
||||
|
||||
from ePub.read_epub import EPUB
|
||||
from lector.readers.read_epub import EPUB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ParseEPUB:
|
||||
def __init__(self, filename, temp_dir, file_md5):
|
||||
# TODO
|
||||
# Maybe also include book description
|
||||
self.book_ref = None
|
||||
self.book = None
|
||||
self.filename = filename
|
||||
self.temp_dir = temp_dir
|
||||
self.extract_path = os.path.join(temp_dir, file_md5)
|
||||
|
||||
def read_book(self):
|
||||
self.book_ref = EPUB(self.filename)
|
||||
contents_found = self.book_ref.read_epub()
|
||||
if not contents_found:
|
||||
print('Cannot process: ' + self.filename)
|
||||
return
|
||||
self.book = self.book_ref.book
|
||||
self.book = EPUB(self.filename, self.temp_dir)
|
||||
|
||||
def get_title(self):
|
||||
return self.book['title']
|
||||
def generate_metadata(self):
|
||||
self.book.generate_metadata()
|
||||
return self.book.metadata
|
||||
|
||||
def get_author(self):
|
||||
return self.book['author']
|
||||
|
||||
def get_year(self):
|
||||
return self.book['year']
|
||||
|
||||
def get_cover_image(self):
|
||||
return self.book['cover']
|
||||
|
||||
def get_isbn(self):
|
||||
return self.book['isbn']
|
||||
|
||||
def get_tags(self):
|
||||
return self.book['tags']
|
||||
|
||||
def get_contents(self):
|
||||
def generate_content(self):
|
||||
zipfile.ZipFile(self.filename).extractall(self.extract_path)
|
||||
|
||||
self.book_ref.parse_chapters(temp_dir=self.extract_path)
|
||||
file_settings = {
|
||||
'images_only': False}
|
||||
return self.book['book_list'], file_settings
|
||||
self.book.generate_toc()
|
||||
self.book.generate_content()
|
||||
|
||||
toc = []
|
||||
content = []
|
||||
for count, i in enumerate(self.book.content):
|
||||
toc.append((i[0], i[1], count + 1))
|
||||
content.append(i[2])
|
||||
|
||||
# Return toc, content, images_only
|
||||
return toc, content, False
|
52
lector/parsers/fb2.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# This file is a part of Lector, a Qt based ebook reader
|
||||
# Copyright (C) 2017-2019 BasioMeusPuga
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# TODO
|
||||
# Maybe also include book description
|
||||
|
||||
import os
|
||||
import logging
|
||||
|
||||
from lector.readers.read_fb2 import FB2
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ParseFB2:
|
||||
def __init__(self, filename, temp_dir, file_md5):
|
||||
self.book = None
|
||||
self.filename = filename
|
||||
self.extract_path = os.path.join(temp_dir, file_md5)
|
||||
|
||||
def read_book(self):
|
||||
self.book = FB2(self.filename)
|
||||
|
||||
def generate_metadata(self):
|
||||
self.book.generate_metadata()
|
||||
return self.book.metadata
|
||||
|
||||
def generate_content(self):
|
||||
os.makedirs(self.extract_path, exist_ok=True) # Manual creation is required here
|
||||
self.book.generate_content(temp_dir=self.extract_path)
|
||||
|
||||
toc = []
|
||||
content = []
|
||||
for count, i in enumerate(self.book.content):
|
||||
toc.append((i[0], i[1], count + 1))
|
||||
content.append(i[2])
|
||||
|
||||
# Return toc, content, images_only
|
||||
return toc, content, False
|
@@ -1,7 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# This file is a part of Lector, a Qt based ebook reader
|
||||
# Copyright (C) 2017 BasioMeusPuga
|
||||
# Copyright (C) 2017-2019 BasioMeusPuga
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -16,78 +14,69 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# This module parses Amazon ebooks using KindleUnpack to first create an
|
||||
# epub that is then read the usual way
|
||||
# TODO
|
||||
# See if it's possible to just feed the
|
||||
# unzipped mobi7 file into the EPUB parser module
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import zipfile
|
||||
import logging
|
||||
|
||||
from ePub.read_epub import EPUB
|
||||
import KindleUnpack.kindleunpack as KindleUnpack
|
||||
from lector.readers.read_epub import EPUB
|
||||
import lector.KindleUnpack.kindleunpack as KindleUnpack
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ParseMOBI:
|
||||
# This module parses Amazon ebooks using KindleUnpack to first create an
|
||||
# epub and then read the usual way
|
||||
|
||||
def __init__(self, filename, temp_dir, file_md5):
|
||||
self.book_ref = None
|
||||
self.book = None
|
||||
self.filename = filename
|
||||
self.epub_filepath = None
|
||||
self.split_large_xml = False
|
||||
self.temp_dir = temp_dir
|
||||
self.extract_dir = os.path.join(temp_dir, file_md5)
|
||||
self.extract_path = os.path.join(temp_dir, file_md5)
|
||||
|
||||
def read_book(self):
|
||||
with HidePrinting():
|
||||
KindleUnpack.unpackBook(self.filename, self.extract_dir)
|
||||
KindleUnpack.unpackBook(self.filename, self.extract_path)
|
||||
|
||||
epub_filename = os.path.splitext(
|
||||
os.path.basename(self.filename))[0] + '.epub'
|
||||
|
||||
self.epub_filepath = os.path.join(
|
||||
self.extract_dir, 'mobi8', epub_filename)
|
||||
self.extract_path, 'mobi8', epub_filename)
|
||||
|
||||
if not os.path.exists(self.epub_filepath):
|
||||
zip_dir = os.path.join(self.extract_dir, 'mobi7')
|
||||
zip_dir = os.path.join(self.extract_path, 'mobi7')
|
||||
zip_file = os.path.join(
|
||||
self.extract_dir, epub_filename)
|
||||
self.extract_path, epub_filename)
|
||||
self.epub_filepath = shutil.make_archive(zip_file, 'zip', zip_dir)
|
||||
self.split_large_xml = True
|
||||
|
||||
self.book_ref = EPUB(self.epub_filepath)
|
||||
contents_found = self.book_ref.read_epub()
|
||||
if not contents_found:
|
||||
print('Cannot process: ' + self.filename)
|
||||
return
|
||||
self.book = self.book_ref.book
|
||||
self.book = EPUB(self.epub_filepath, self.temp_dir)
|
||||
|
||||
def get_title(self):
|
||||
return self.book['title']
|
||||
def generate_metadata(self):
|
||||
self.book.generate_metadata()
|
||||
return self.book.metadata
|
||||
|
||||
def get_author(self):
|
||||
return self.book['author']
|
||||
def generate_content(self):
|
||||
zipfile.ZipFile(self.epub_filepath).extractall(self.extract_path)
|
||||
|
||||
def get_year(self):
|
||||
return self.book['year']
|
||||
self.book.generate_toc()
|
||||
self.book.generate_content()
|
||||
|
||||
def get_cover_image(self):
|
||||
return self.book['cover']
|
||||
toc = []
|
||||
content = []
|
||||
for count, i in enumerate(self.book.content):
|
||||
toc.append((1, i[1], count + 1))
|
||||
content.append(i[2])
|
||||
|
||||
def get_isbn(self):
|
||||
return self.book['isbn']
|
||||
# Return toc, content, images_only
|
||||
return toc, content, False
|
||||
|
||||
def get_tags(self):
|
||||
return self.book['tags']
|
||||
|
||||
def get_contents(self):
|
||||
extract_path = os.path.join(self.extract_dir)
|
||||
zipfile.ZipFile(self.epub_filepath).extractall(extract_path)
|
||||
|
||||
self.book_ref.parse_chapters(
|
||||
temp_dir=self.temp_dir, split_large_xml=self.split_large_xml)
|
||||
file_settings = {
|
||||
'images_only': False}
|
||||
return self.book['book_list'], file_settings
|
||||
|
||||
class HidePrinting:
|
||||
def __enter__(self):
|
100
lector/parsers/pdf.py
Normal file
@@ -0,0 +1,100 @@
|
||||
# This file is a part of Lector, a Qt based ebook reader
|
||||
# Copyright (C) 2017-2019 BasioMeusPuga
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import collections
|
||||
|
||||
import fitz
|
||||
from PyQt5 import QtGui
|
||||
|
||||
|
||||
class ParsePDF:
|
||||
def __init__(self, filename, *args):
|
||||
self.filename = filename
|
||||
self.book = None
|
||||
|
||||
def read_book(self):
|
||||
self.book = fitz.open(self.filename)
|
||||
|
||||
def generate_metadata(self):
|
||||
title = self.book.metadata['title']
|
||||
if not title:
|
||||
title = os.path.splitext(os.path.basename(self.filename))[0]
|
||||
|
||||
author = self.book.metadata['author']
|
||||
if not author:
|
||||
author = 'Unknown'
|
||||
|
||||
creation_date = self.book.metadata['creationDate']
|
||||
try:
|
||||
year = creation_date.split(':')[1][:4]
|
||||
except (ValueError, AttributeError):
|
||||
year = 9999
|
||||
|
||||
isbn = None
|
||||
|
||||
tags = self.book.metadata['keywords']
|
||||
if not tags:
|
||||
tags = []
|
||||
|
||||
# This is a little roundabout for the cover
|
||||
# and I'm sure it's taking a performance hit
|
||||
# But it is simple. So there's that.
|
||||
cover_page = self.book.loadPage(0)
|
||||
# Disabling scaling gets the covers much faster
|
||||
cover = render_pdf_page(cover_page, True)
|
||||
|
||||
Metadata = collections.namedtuple(
|
||||
'Metadata', ['title', 'author', 'year', 'isbn', 'tags', 'cover'])
|
||||
return Metadata(title, author, year, isbn, tags, cover)
|
||||
|
||||
def generate_content(self):
|
||||
content = list(range(self.book.pageCount))
|
||||
toc = self.book.getToC()
|
||||
if not toc:
|
||||
toc = [(1, f'Page {i + 1}', i + 1) for i in range(self.book.pageCount)]
|
||||
|
||||
# Return toc, content, images_only
|
||||
return toc, content, True
|
||||
|
||||
|
||||
def render_pdf_page(page_data, for_cover=False):
|
||||
# Draw page contents on to a pixmap
|
||||
# and then return that pixmap
|
||||
|
||||
# Render quality is set by the following
|
||||
zoom_matrix = fitz.Matrix(4, 4)
|
||||
if for_cover:
|
||||
zoom_matrix = fitz.Matrix(1, 1)
|
||||
|
||||
pagePixmap = page_data.getPixmap(
|
||||
matrix=zoom_matrix,
|
||||
alpha=False) # Sets background to White
|
||||
imageFormat = QtGui.QImage.Format_RGB888 # Set to Format_RGB888 if alpha
|
||||
pageQImage = QtGui.QImage(
|
||||
pagePixmap.samples,
|
||||
pagePixmap.width,
|
||||
pagePixmap.height,
|
||||
pagePixmap.stride,
|
||||
imageFormat)
|
||||
|
||||
# The cover page doesn't require conversion into a Pixmap
|
||||
if for_cover:
|
||||
return pageQImage
|
||||
|
||||
pixmap = QtGui.QPixmap()
|
||||
pixmap.convertFromImage(pageQImage)
|
||||
return pixmap
|
476
lector/readers/read_epub.py
Normal file
@@ -0,0 +1,476 @@
|
||||
# This file is a part of Lector, a Qt based ebook reader
|
||||
# Copyright (C) 2017-2019 BasioMeusPuga
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# TODO
|
||||
# See if inserting chapters not in the toc.ncx can be avoided
|
||||
# Account for stylesheets... eventually
|
||||
|
||||
import os
|
||||
import zipfile
|
||||
import logging
|
||||
import collections
|
||||
from urllib.parse import unquote
|
||||
|
||||
import xmltodict
|
||||
from PyQt5 import QtGui
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EPUB:
|
||||
def __init__(self, book_filename, temp_dir):
|
||||
self.book_filename = book_filename
|
||||
self.temp_dir = temp_dir
|
||||
|
||||
self.zip_file = None
|
||||
self.file_list = None
|
||||
self.opf_dict = None
|
||||
self.cover_image_name = None
|
||||
self.split_chapters = {}
|
||||
|
||||
self.metadata = None
|
||||
self.content = []
|
||||
|
||||
self.generate_references()
|
||||
|
||||
def generate_references(self):
|
||||
self.zip_file = zipfile.ZipFile(
|
||||
self.book_filename, mode='r', allowZip64=True)
|
||||
self.file_list = self.zip_file.namelist()
|
||||
|
||||
# Book structure relies on parsing the .opf file
|
||||
# in the book. Now that might be the usual content.opf
|
||||
# or package.opf or it might be named after your favorite
|
||||
# eldritch abomination. The point is we have to check
|
||||
# the container.xml
|
||||
container = self.find_file('container.xml')
|
||||
if container:
|
||||
container_xml = self.zip_file.read(container)
|
||||
container_dict = xmltodict.parse(container_xml)
|
||||
packagefile = container_dict['container']['rootfiles']['rootfile']['@full-path']
|
||||
else:
|
||||
presumptive_names = ('content.opf', 'package.opf', 'volume.opf')
|
||||
for i in presumptive_names:
|
||||
packagefile = self.find_file(i)
|
||||
if packagefile:
|
||||
logger.info('Using presumptive package file: ' + self.book_filename)
|
||||
break
|
||||
|
||||
packagefile_data = self.zip_file.read(packagefile)
|
||||
self.opf_dict = xmltodict.parse(packagefile_data)
|
||||
|
||||
def find_file(self, filename):
|
||||
# Get rid of special characters
|
||||
filename = unquote(filename)
|
||||
|
||||
# First, look for the file in the root of the book
|
||||
if filename in self.file_list:
|
||||
return filename
|
||||
|
||||
# Then search for it elsewhere
|
||||
else:
|
||||
file_basename = os.path.basename(filename)
|
||||
for i in self.file_list:
|
||||
if os.path.basename(i) == file_basename:
|
||||
return i
|
||||
|
||||
# If the file isn't found
|
||||
logger.error(filename + ' not found in ' + self.book_filename)
|
||||
return False
|
||||
|
||||
def generate_toc(self):
|
||||
def find_alternative_toc():
|
||||
toc_filename = None
|
||||
toc_filename_alternative = None
|
||||
manifest = self.opf_dict['package']['manifest']['item']
|
||||
|
||||
for i in manifest:
|
||||
# Behold the burning hoops we're jumping through
|
||||
if i['@id'] == 'ncx':
|
||||
toc_filename = i['@href']
|
||||
if ('ncx' in i['@id']) or ('toc' in i['@id']):
|
||||
toc_filename_alternative = i['@href']
|
||||
if toc_filename and toc_filename_alternative:
|
||||
break
|
||||
|
||||
if not toc_filename:
|
||||
if not toc_filename_alternative:
|
||||
logger.error('No ToC found for: ' + self.book_filename)
|
||||
else:
|
||||
toc_filename = toc_filename_alternative
|
||||
|
||||
logger.info('Using alternate ToC for: ' + self.book_filename)
|
||||
return toc_filename
|
||||
|
||||
# Find the toc.ncx file from the manifest
|
||||
# EPUBs will name literally anything, anything so try
|
||||
# a less stringent approach if the first one doesn't work
|
||||
# The idea is to prioritize 'toc.ncx' since this should work
|
||||
# for the vast majority of books
|
||||
toc_filename = 'toc.ncx'
|
||||
does_toc_exist = self.find_file(toc_filename)
|
||||
if not does_toc_exist:
|
||||
toc_filename = find_alternative_toc()
|
||||
|
||||
tocfile = self.find_file(toc_filename)
|
||||
tocfile_data = self.zip_file.read(tocfile)
|
||||
toc_dict = xmltodict.parse(tocfile_data)
|
||||
|
||||
def recursor(level, nav_node):
|
||||
if isinstance(nav_node, list):
|
||||
these_contents = [[
|
||||
level + 1,
|
||||
i['navLabel']['text'],
|
||||
i['content']['@src']] for i in nav_node]
|
||||
self.content.extend(these_contents)
|
||||
return
|
||||
|
||||
if 'navPoint' in nav_node.keys():
|
||||
recursor(level, nav_node['navPoint'])
|
||||
|
||||
else:
|
||||
self.content.append([
|
||||
level + 1,
|
||||
nav_node['navLabel']['text'],
|
||||
nav_node['content']['@src']])
|
||||
|
||||
navpoints = toc_dict['ncx']['navMap']['navPoint']
|
||||
for top_level_nav in navpoints:
|
||||
# Just one chapter
|
||||
if isinstance(top_level_nav, str):
|
||||
self.content.append([
|
||||
1,
|
||||
navpoints['navLabel']['text'],
|
||||
navpoints['content']['@src']])
|
||||
break
|
||||
|
||||
# Multiple chapters
|
||||
self.content.append([
|
||||
1,
|
||||
top_level_nav['navLabel']['text'],
|
||||
top_level_nav['content']['@src']])
|
||||
|
||||
if 'navPoint' in top_level_nav.keys():
|
||||
recursor(1, top_level_nav)
|
||||
|
||||
def get_chapter_content(self, chapter_file):
|
||||
this_file = self.find_file(chapter_file)
|
||||
if this_file:
|
||||
chapter_content = self.zip_file.read(this_file).decode()
|
||||
|
||||
# Generate a None return for a blank chapter
|
||||
# These will be removed from the contents later
|
||||
contentDocument = QtGui.QTextDocument(None)
|
||||
contentDocument.setHtml(chapter_content)
|
||||
contentText = contentDocument.toPlainText().replace('\n', '')
|
||||
if contentText == '':
|
||||
chapter_content = None
|
||||
|
||||
return chapter_content
|
||||
else:
|
||||
return 'Possible parse error: ' + chapter_file
|
||||
|
||||
def parse_split_chapters(self, chapters_with_split_content):
|
||||
# For split chapters, get the whole chapter first, then split
|
||||
# between ids using their anchors, then "heal" the resultant text
|
||||
# by creating a BeautifulSoup object. Write its str to the content
|
||||
for i in chapters_with_split_content.items():
|
||||
chapter_file = i[0]
|
||||
self.split_chapters[chapter_file] = {}
|
||||
|
||||
chapter_content = self.get_chapter_content(chapter_file)
|
||||
soup = BeautifulSoup(chapter_content, 'lxml')
|
||||
|
||||
split_anchors = i[1]
|
||||
for this_anchor in reversed(split_anchors):
|
||||
this_tag = soup.find(
|
||||
attrs={"id":lambda x: x == this_anchor})
|
||||
|
||||
markup_split = str(soup).split(str(this_tag))
|
||||
soup = BeautifulSoup(markup_split[0], 'lxml')
|
||||
|
||||
# If the tag is None, it probably means the content is overlapping
|
||||
# Skipping the insert is the way forward
|
||||
if this_tag:
|
||||
this_markup = BeautifulSoup(
|
||||
str(this_tag).strip() + markup_split[1], 'lxml')
|
||||
self.split_chapters[chapter_file][this_anchor] = str(this_markup)
|
||||
|
||||
# Remaining markup is assigned here
|
||||
self.split_chapters[chapter_file]['top_level'] = str(soup)
|
||||
|
||||
def generate_content(self):
|
||||
# Find all the chapters mentioned in the opf spine
|
||||
# These are simply ids that correspond to the actual item
|
||||
# as mentioned in the manifest - which is a comprehensive
|
||||
# list of files
|
||||
try:
|
||||
# Multiple chapters
|
||||
chapters_in_spine = [
|
||||
i['@idref']
|
||||
for i in self.opf_dict['package']['spine']['itemref']]
|
||||
except TypeError:
|
||||
# Single chapter - Large xml
|
||||
chapters_in_spine = [
|
||||
self.opf_dict['package']['spine']['itemref']['@idref']]
|
||||
|
||||
# Next, find items and ids from the manifest
|
||||
# This might error out in case there's only one item in
|
||||
# the manifest. Remember that for later.
|
||||
chapters_from_manifest = {
|
||||
i['@id']: i['@href']
|
||||
for i in self.opf_dict['package']['manifest']['item']}
|
||||
|
||||
# Finally, check which items are supposed to be in the spine
|
||||
# on the basis of the id and change the toc accordingly
|
||||
spine_final = []
|
||||
for i in chapters_in_spine:
|
||||
try:
|
||||
spine_final.append(chapters_from_manifest.pop(i))
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
toc_chapters = [
|
||||
unquote(i[2].split('#')[0]) for i in self.content]
|
||||
|
||||
for i in spine_final:
|
||||
if not i in toc_chapters:
|
||||
spine_index = spine_final.index(i)
|
||||
if spine_index == 0: # Or chapter insertion circles back to the end
|
||||
previous_chapter_toc_index = -1
|
||||
else:
|
||||
previous_chapter = spine_final[spine_final.index(i) - 1]
|
||||
previous_chapter_toc_index = toc_chapters.index(previous_chapter)
|
||||
|
||||
toc_chapters.insert(
|
||||
previous_chapter_toc_index + 1, i)
|
||||
self.content.insert(
|
||||
previous_chapter_toc_index + 1, [1, None, i])
|
||||
|
||||
# Parse split chapters as below
|
||||
# They can be picked up during the iteration through the toc
|
||||
chapters_with_split_content = {}
|
||||
for i in self.content:
|
||||
if '#' in i[2]:
|
||||
this_split = i[2].split('#')
|
||||
chapter = this_split[0]
|
||||
anchor = this_split[1]
|
||||
|
||||
try:
|
||||
chapters_with_split_content[chapter].append(anchor)
|
||||
except KeyError:
|
||||
chapters_with_split_content[chapter] = []
|
||||
chapters_with_split_content[chapter].append(anchor)
|
||||
|
||||
self.parse_split_chapters(chapters_with_split_content)
|
||||
|
||||
# Now we iterate over the ToC as presented in the toc.ncx
|
||||
# and add chapters to the content list
|
||||
# In case a split chapter is encountered, get its content
|
||||
# from the split_chapters dictionary
|
||||
# What could possibly go wrong?
|
||||
toc_copy = self.content[:]
|
||||
|
||||
# Put the book into the book
|
||||
for count, i in enumerate(toc_copy):
|
||||
chapter_file = i[2]
|
||||
|
||||
# Get split content according to its corresponding id attribute
|
||||
if '#' in chapter_file:
|
||||
this_split = chapter_file.split('#')
|
||||
chapter_file_proper = this_split[0]
|
||||
this_anchor = this_split[1]
|
||||
|
||||
try:
|
||||
chapter_content = (
|
||||
self.split_chapters[chapter_file_proper][this_anchor])
|
||||
except KeyError:
|
||||
chapter_content = 'Parse Error'
|
||||
error_string = (
|
||||
f'Error parsing {self.book_filename}: {chapter_file_proper}')
|
||||
logger.error(error_string)
|
||||
|
||||
# Get content that remained at the end of the pillaging above
|
||||
elif chapter_file in self.split_chapters.keys():
|
||||
try:
|
||||
chapter_content = self.split_chapters[chapter_file]['top_level']
|
||||
except KeyError:
|
||||
chapter_content = 'Parse Error'
|
||||
error_string = (
|
||||
f'Error parsing {self.book_filename}: {chapter_file}')
|
||||
logger.error(error_string)
|
||||
|
||||
# Vanilla non split chapters
|
||||
else:
|
||||
chapter_content = self.get_chapter_content(chapter_file)
|
||||
|
||||
self.content[count][2] = chapter_content
|
||||
|
||||
# Cleanup content by removing null chapters
|
||||
unnamed_chapter_title = 1
|
||||
content_copy = []
|
||||
for i in self.content:
|
||||
if i[2]:
|
||||
chapter_title = i[1]
|
||||
if not chapter_title:
|
||||
chapter_title = unnamed_chapter_title
|
||||
content_copy.append((
|
||||
i[0], str(chapter_title), i[2]))
|
||||
unnamed_chapter_title += 1
|
||||
self.content = content_copy
|
||||
|
||||
# Get cover image and put it in its place
|
||||
# I imagine this involves saying nasty things to it
|
||||
# There's no point shifting this to the parser
|
||||
# The performance increase is negligible
|
||||
cover_image = self.generate_book_cover()
|
||||
|
||||
if cover_image:
|
||||
cover_path = os.path.join(
|
||||
self.temp_dir, os.path.basename(self.book_filename)) + ' - cover'
|
||||
with open(cover_path, 'wb') as cover_temp:
|
||||
cover_temp.write(cover_image)
|
||||
|
||||
# This is probably stupid, but I can't stand the idea of
|
||||
# having to look at two book covers
|
||||
cover_replacement_conditions = (
|
||||
self.cover_image_name.lower() + '.jpg' in self.content[0][2].lower(),
|
||||
self.cover_image_name.lower() + '.png' in self.content[0][2].lower(),
|
||||
'cover' in self.content[0][1].lower())
|
||||
|
||||
if True in cover_replacement_conditions:
|
||||
logger.info(
|
||||
f'Replacing cover {cover_replacement_conditions}: {self.book_filename}')
|
||||
self.content[0] = (
|
||||
1, 'Cover',
|
||||
f'<center><img src="{cover_path}" alt="Cover"></center>')
|
||||
else:
|
||||
logger.info('Adding cover: ' + self.book_filename)
|
||||
self.content.insert(
|
||||
0,
|
||||
(1, 'Cover',
|
||||
f'<center><img src="{cover_path}" alt="Cover"></center>'))
|
||||
|
||||
def generate_metadata(self):
|
||||
book_metadata = self.opf_dict['package']['metadata']
|
||||
|
||||
def flattener(this_object):
|
||||
if isinstance(this_object, collections.OrderedDict):
|
||||
return this_object['#text']
|
||||
|
||||
if isinstance(this_object, list):
|
||||
if isinstance(this_object[0], collections.OrderedDict):
|
||||
return this_object[0]['#text']
|
||||
else:
|
||||
return this_object[0]
|
||||
|
||||
if isinstance(this_object, str):
|
||||
return this_object
|
||||
|
||||
# There are no exception types specified below
|
||||
# This is on purpose and makes me long for the days
|
||||
# of simpler, happier things.
|
||||
|
||||
# Book title
|
||||
try:
|
||||
title = flattener(book_metadata['dc:title'])
|
||||
except:
|
||||
logger.warning('Title not found: ' + self.book_filename)
|
||||
title = os.path.splitext(
|
||||
os.path.basename(self.book_filename))[0]
|
||||
|
||||
# Book author
|
||||
try:
|
||||
author = flattener(book_metadata['dc:creator'])
|
||||
except:
|
||||
logger.warning('Author not found: ' + self.book_filename)
|
||||
author = 'Unknown'
|
||||
|
||||
# Book year
|
||||
try:
|
||||
year = int(flattener(book_metadata['dc:date'])[:4])
|
||||
except:
|
||||
logger.warning('Year not found: ' + self.book_filename)
|
||||
year = 9999
|
||||
|
||||
# Book isbn
|
||||
# Both one and multiple schema
|
||||
isbn = None
|
||||
try:
|
||||
scheme = book_metadata['dc:identifier']['@opf:scheme'].lower()
|
||||
if scheme.lower() == 'isbn':
|
||||
isbn = book_metadata['dc:identifier']['#text']
|
||||
|
||||
except (TypeError, KeyError):
|
||||
try:
|
||||
for i in book_metadata['dc:identifier']:
|
||||
if i['@opf:scheme'].lower() == 'isbn':
|
||||
isbn = i['#text']
|
||||
break
|
||||
except:
|
||||
logger.warning('ISBN not found: ' + self.book_filename)
|
||||
|
||||
# Book tags
|
||||
try:
|
||||
tags = book_metadata['dc:subject']
|
||||
if isinstance(tags, str):
|
||||
tags = [tags]
|
||||
except:
|
||||
tags = []
|
||||
|
||||
# Book cover
|
||||
cover = self.generate_book_cover()
|
||||
|
||||
# Named tuple? Named tuple.
|
||||
Metadata = collections.namedtuple(
|
||||
'Metadata', ['title', 'author', 'year', 'isbn', 'tags', 'cover'])
|
||||
self.metadata = Metadata(title, author, year, isbn, tags, cover)
|
||||
|
||||
def generate_book_cover(self):
|
||||
# This is separate because the book cover needs to
|
||||
# be found and extracted both during addition / reading
|
||||
book_cover = None
|
||||
|
||||
try:
|
||||
cover_image = [
|
||||
i['@href'] for i in self.opf_dict['package']['manifest']['item']
|
||||
if i['@media-type'].split('/')[0] == 'image' and
|
||||
'cover' in i['@id']][0]
|
||||
book_cover = self.zip_file.read(self.find_file(cover_image))
|
||||
self.cover_image_name = os.path.splitext(
|
||||
os.path.basename(cover_image))[0]
|
||||
except:
|
||||
pass
|
||||
|
||||
# Find book cover the hard way
|
||||
if not book_cover:
|
||||
biggest_image_size = 0
|
||||
biggest_image = None
|
||||
for j in self.zip_file.filelist:
|
||||
if os.path.splitext(j.filename)[1] in ['.jpg', '.jpeg', '.png', '.gif']:
|
||||
if j.file_size > biggest_image_size:
|
||||
biggest_image = j.filename
|
||||
biggest_image_size = j.file_size
|
||||
|
||||
if biggest_image:
|
||||
book_cover = self.zip_file.read(
|
||||
self.find_file(biggest_image))
|
||||
|
||||
if not book_cover:
|
||||
logger.warning('Cover not found: ' + self.book_filename)
|
||||
|
||||
return book_cover
|
176
lector/readers/read_fb2.py
Normal file
@@ -0,0 +1,176 @@
|
||||
# This file is a part of Lector, a Qt based ebook reader
|
||||
# Copyright (C) 2017-2019 BasioMeusPuga
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import base64
|
||||
import zipfile
|
||||
import logging
|
||||
import collections
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FB2:
|
||||
def __init__(self, filename):
|
||||
self.filename = filename
|
||||
self.zip_file = None
|
||||
self.xml = None
|
||||
|
||||
self.metadata = None
|
||||
self.content = []
|
||||
|
||||
self.generate_references()
|
||||
|
||||
def generate_references(self):
|
||||
if self.filename.endswith('.fb2.zip'):
|
||||
this_book = zipfile.ZipFile(
|
||||
self.filename, mode='r', allowZip64=True)
|
||||
for i in this_book.filelist:
|
||||
if os.path.splitext(i.filename)[1] == '.fb2':
|
||||
book_text = this_book.read(i.filename)
|
||||
break
|
||||
|
||||
else:
|
||||
with open(self.filename, 'r') as book_file:
|
||||
book_text = book_file.read()
|
||||
|
||||
self.xml = BeautifulSoup(book_text, 'lxml')
|
||||
|
||||
def generate_metadata(self):
|
||||
# All metadata can be parsed in one pass
|
||||
all_tags = self.xml.find('description')
|
||||
|
||||
title = all_tags.find('book-title').text
|
||||
if title == '' or title is None:
|
||||
title = os.path.splitext(
|
||||
os.path.basename(self.filename))[0]
|
||||
|
||||
author = all_tags.find(
|
||||
'author').getText(separator=' ').replace('\n', ' ')
|
||||
if author == '' or author is None:
|
||||
author = '<Unknown>'
|
||||
else:
|
||||
author = author.strip()
|
||||
|
||||
# TODO
|
||||
# Account for other date formats
|
||||
try:
|
||||
year = int(all_tags.find('date').text)
|
||||
except ValueError:
|
||||
year = 9999
|
||||
|
||||
isbn = None
|
||||
tags = None
|
||||
|
||||
cover = self.generate_book_cover()
|
||||
|
||||
Metadata = collections.namedtuple(
|
||||
'Metadata', ['title', 'author', 'year', 'isbn', 'tags', 'cover'])
|
||||
self.metadata = Metadata(title, author, year, isbn, tags, cover)
|
||||
|
||||
def generate_content(self, temp_dir):
|
||||
# TODO
|
||||
# Check what's up with recursion levels
|
||||
# Why is the TypeError happening in get_title
|
||||
|
||||
def get_title(element):
|
||||
this_title = '<No title>'
|
||||
title_xml = '<No title xml>'
|
||||
try:
|
||||
for i in element:
|
||||
if i.name == 'title':
|
||||
this_title = i.getText(separator=' ')
|
||||
this_title = this_title.replace('\n', '').strip()
|
||||
title_xml = str(i.unwrap())
|
||||
break
|
||||
except TypeError:
|
||||
return None, None
|
||||
return this_title, title_xml
|
||||
|
||||
def recursor(level, element):
|
||||
children = element.findChildren('section', recursive=False)
|
||||
if not children and level != 1:
|
||||
this_title, title_xml = get_title(element)
|
||||
self.content.append(
|
||||
[level, this_title, title_xml + str(element)])
|
||||
else:
|
||||
for i in children:
|
||||
recursor(level + 1, i)
|
||||
|
||||
first_element = self.xml.find('section') # Recursive find
|
||||
siblings = list(first_element.findNextSiblings('section', recursive=False))
|
||||
siblings.insert(0, first_element)
|
||||
|
||||
for this_element in siblings:
|
||||
this_title, title_xml = get_title(this_element)
|
||||
# Do not add chapter content in case it has sections
|
||||
# inside it. This prevents having large Book sections that
|
||||
# have duplicated content
|
||||
section_children = this_element.findChildren('section')
|
||||
chapter_text = str(this_element)
|
||||
if section_children:
|
||||
chapter_text = this_title
|
||||
|
||||
self.content.append([1, this_title, chapter_text])
|
||||
recursor(1, this_element)
|
||||
|
||||
# Extract all images to the temp_dir
|
||||
for i in self.xml.find_all('binary'):
|
||||
image_name = i.get('id')
|
||||
image_path = os.path.join(temp_dir, image_name)
|
||||
image_string = f'<image l:href="#{image_name}"'
|
||||
replacement_string = f'<p></p><img src=\"{image_path}\"'
|
||||
|
||||
for j in self.content:
|
||||
j[2] = j[2].replace(
|
||||
image_string, replacement_string)
|
||||
try:
|
||||
image_data = base64.decodebytes(i.text.encode())
|
||||
with open(image_path, 'wb') as outimage:
|
||||
outimage.write(image_data)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
# Insert the book cover at the beginning
|
||||
cover_image = self.generate_book_cover()
|
||||
if cover_image:
|
||||
cover_path = os.path.join(
|
||||
temp_dir, os.path.basename(self.filename)) + ' - cover'
|
||||
with open(cover_path, 'wb') as cover_temp:
|
||||
cover_temp.write(cover_image)
|
||||
|
||||
self.content.insert(
|
||||
0, (1, 'Cover', f'<center><img src="{cover_path}" alt="Cover"></center>'))
|
||||
|
||||
def generate_book_cover(self):
|
||||
cover = None
|
||||
|
||||
try:
|
||||
cover_image_xml = self.xml.find('coverpage')
|
||||
for i in cover_image_xml:
|
||||
cover_image_name = i.get('l:href')
|
||||
|
||||
cover_image_data = self.xml.find_all('binary')
|
||||
for i in cover_image_data:
|
||||
if cover_image_name.endswith(i.get('id')):
|
||||
cover = base64.decodebytes(i.text.encode())
|
||||
except (AttributeError, TypeError):
|
||||
# Catch TypeError in case no images exist in the book
|
||||
logger.warning('Cover not found: ' + self.filename)
|
||||
|
||||
return cover
|
@@ -10,5 +10,7 @@
|
||||
<p>Author: BasioMeusPuga <a href="mailto:disgruntled.mob@gmail.com">disgruntled.mob@gmail.com</a></p>
|
||||
<p>Page: <a href="https://github.com/BasioMeusPuga/Lector">https://github.com/BasioMeusPuga/Lector</a></p>
|
||||
<p>License: GPLv3 <a href="https://www.gnu.org/licenses/gpl-3.0.en.html">https://www.gnu.org/licenses/gpl-3.0.en.html</a></p>
|
||||
<p>Donate (Paypal): <a href="https://www.paypal.me/supportlector">https://www.paypal.me/supportlector</p>
|
||||
<p>Donate (Bitcoin): 17jaxj26vFJNqQ2hEVerbBV5fpTusfqFro</p>
|
||||
<p> </p></body>
|
||||
</html>
|
146
lector/resources/annotationswindow.py
Normal file
@@ -0,0 +1,146 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Form implementation generated from reading ui file 'raw/annotations.ui'
|
||||
#
|
||||
# Created by: PyQt5 UI code generator 5.10.1
|
||||
#
|
||||
# WARNING! All changes made in this file will be lost!
|
||||
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
|
||||
class Ui_Dialog(object):
|
||||
def setupUi(self, Dialog):
|
||||
Dialog.setObjectName("Dialog")
|
||||
Dialog.resize(306, 387)
|
||||
self.gridLayout = QtWidgets.QGridLayout(Dialog)
|
||||
self.gridLayout.setObjectName("gridLayout")
|
||||
self.verticalLayout = QtWidgets.QVBoxLayout()
|
||||
self.verticalLayout.setObjectName("verticalLayout")
|
||||
self.horizontalLayout_2 = QtWidgets.QHBoxLayout()
|
||||
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
|
||||
self.nameEdit = QtWidgets.QLineEdit(Dialog)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.nameEdit.sizePolicy().hasHeightForWidth())
|
||||
self.nameEdit.setSizePolicy(sizePolicy)
|
||||
self.nameEdit.setObjectName("nameEdit")
|
||||
self.horizontalLayout_2.addWidget(self.nameEdit)
|
||||
self.verticalLayout.addLayout(self.horizontalLayout_2)
|
||||
self.horizontalLayout = QtWidgets.QHBoxLayout()
|
||||
self.horizontalLayout.setObjectName("horizontalLayout")
|
||||
self.typeLabel = QtWidgets.QLabel(Dialog)
|
||||
self.typeLabel.setObjectName("typeLabel")
|
||||
self.horizontalLayout.addWidget(self.typeLabel)
|
||||
self.typeBox = QtWidgets.QComboBox(Dialog)
|
||||
self.typeBox.setObjectName("typeBox")
|
||||
self.horizontalLayout.addWidget(self.typeBox)
|
||||
self.verticalLayout.addLayout(self.horizontalLayout)
|
||||
self.stackedWidget = QtWidgets.QStackedWidget(Dialog)
|
||||
self.stackedWidget.setObjectName("stackedWidget")
|
||||
self.page = QtWidgets.QWidget()
|
||||
self.page.setObjectName("page")
|
||||
self.gridLayout_2 = QtWidgets.QGridLayout(self.page)
|
||||
self.gridLayout_2.setObjectName("gridLayout_2")
|
||||
self.verticalLayout_12 = QtWidgets.QVBoxLayout()
|
||||
self.verticalLayout_12.setObjectName("verticalLayout_12")
|
||||
self.horizontalLayout_15 = QtWidgets.QHBoxLayout()
|
||||
self.horizontalLayout_15.setObjectName("horizontalLayout_15")
|
||||
self.foregroundCheck = QtWidgets.QCheckBox(self.page)
|
||||
self.foregroundCheck.setObjectName("foregroundCheck")
|
||||
self.horizontalLayout_15.addWidget(self.foregroundCheck)
|
||||
self.foregroundColorButton = QtWidgets.QPushButton(self.page)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.foregroundColorButton.sizePolicy().hasHeightForWidth())
|
||||
self.foregroundColorButton.setSizePolicy(sizePolicy)
|
||||
self.foregroundColorButton.setMinimumSize(QtCore.QSize(30, 0))
|
||||
self.foregroundColorButton.setMaximumSize(QtCore.QSize(45, 40))
|
||||
self.foregroundColorButton.setText("")
|
||||
self.foregroundColorButton.setObjectName("foregroundColorButton")
|
||||
self.horizontalLayout_15.addWidget(self.foregroundColorButton)
|
||||
self.verticalLayout_12.addLayout(self.horizontalLayout_15)
|
||||
self.horizontalLayout_16 = QtWidgets.QHBoxLayout()
|
||||
self.horizontalLayout_16.setObjectName("horizontalLayout_16")
|
||||
self.highlightCheck = QtWidgets.QCheckBox(self.page)
|
||||
self.highlightCheck.setObjectName("highlightCheck")
|
||||
self.horizontalLayout_16.addWidget(self.highlightCheck)
|
||||
self.highlightColorButton = QtWidgets.QPushButton(self.page)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.highlightColorButton.sizePolicy().hasHeightForWidth())
|
||||
self.highlightColorButton.setSizePolicy(sizePolicy)
|
||||
self.highlightColorButton.setMinimumSize(QtCore.QSize(30, 24))
|
||||
self.highlightColorButton.setMaximumSize(QtCore.QSize(45, 40))
|
||||
self.highlightColorButton.setText("")
|
||||
self.highlightColorButton.setObjectName("highlightColorButton")
|
||||
self.horizontalLayout_16.addWidget(self.highlightColorButton)
|
||||
self.verticalLayout_12.addLayout(self.horizontalLayout_16)
|
||||
self.horizontalLayout_17 = QtWidgets.QHBoxLayout()
|
||||
self.horizontalLayout_17.setObjectName("horizontalLayout_17")
|
||||
self.boldCheck = QtWidgets.QCheckBox(self.page)
|
||||
self.boldCheck.setObjectName("boldCheck")
|
||||
self.horizontalLayout_17.addWidget(self.boldCheck)
|
||||
self.verticalLayout_12.addLayout(self.horizontalLayout_17)
|
||||
self.horizontalLayout_18 = QtWidgets.QHBoxLayout()
|
||||
self.horizontalLayout_18.setObjectName("horizontalLayout_18")
|
||||
self.italicCheck = QtWidgets.QCheckBox(self.page)
|
||||
self.italicCheck.setObjectName("italicCheck")
|
||||
self.horizontalLayout_18.addWidget(self.italicCheck)
|
||||
self.verticalLayout_12.addLayout(self.horizontalLayout_18)
|
||||
self.horizontalLayout_19 = QtWidgets.QHBoxLayout()
|
||||
self.horizontalLayout_19.setObjectName("horizontalLayout_19")
|
||||
self.underlineCheck = QtWidgets.QCheckBox(self.page)
|
||||
self.underlineCheck.setObjectName("underlineCheck")
|
||||
self.horizontalLayout_19.addWidget(self.underlineCheck)
|
||||
self.underlineType = QtWidgets.QComboBox(self.page)
|
||||
self.underlineType.setObjectName("underlineType")
|
||||
self.horizontalLayout_19.addWidget(self.underlineType)
|
||||
self.underlineColorButton = QtWidgets.QPushButton(self.page)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.underlineColorButton.sizePolicy().hasHeightForWidth())
|
||||
self.underlineColorButton.setSizePolicy(sizePolicy)
|
||||
self.underlineColorButton.setMinimumSize(QtCore.QSize(45, 24))
|
||||
self.underlineColorButton.setMaximumSize(QtCore.QSize(45, 40))
|
||||
self.underlineColorButton.setText("")
|
||||
self.underlineColorButton.setObjectName("underlineColorButton")
|
||||
self.horizontalLayout_19.addWidget(self.underlineColorButton)
|
||||
self.verticalLayout_12.addLayout(self.horizontalLayout_19)
|
||||
self.gridLayout_2.addLayout(self.verticalLayout_12, 0, 0, 1, 1)
|
||||
self.stackedWidget.addWidget(self.page)
|
||||
self.verticalLayout.addWidget(self.stackedWidget)
|
||||
self.gridLayout.addLayout(self.verticalLayout, 0, 0, 1, 1)
|
||||
self.horizontalLayout_3 = QtWidgets.QHBoxLayout()
|
||||
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
|
||||
spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
|
||||
self.horizontalLayout_3.addItem(spacerItem)
|
||||
self.okButton = QtWidgets.QPushButton(Dialog)
|
||||
self.okButton.setObjectName("okButton")
|
||||
self.horizontalLayout_3.addWidget(self.okButton)
|
||||
self.cancelButton = QtWidgets.QPushButton(Dialog)
|
||||
self.cancelButton.setObjectName("cancelButton")
|
||||
self.horizontalLayout_3.addWidget(self.cancelButton)
|
||||
spacerItem1 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
|
||||
self.horizontalLayout_3.addItem(spacerItem1)
|
||||
self.gridLayout.addLayout(self.horizontalLayout_3, 1, 0, 1, 1)
|
||||
|
||||
self.retranslateUi(Dialog)
|
||||
QtCore.QMetaObject.connectSlotsByName(Dialog)
|
||||
|
||||
def retranslateUi(self, Dialog):
|
||||
_translate = QtCore.QCoreApplication.translate
|
||||
Dialog.setWindowTitle(_translate("Dialog", "Annotation Editor"))
|
||||
self.nameEdit.setPlaceholderText(_translate("Dialog", "Annotation Name"))
|
||||
self.typeLabel.setText(_translate("Dialog", "Type"))
|
||||
self.foregroundCheck.setText(_translate("Dialog", "Foreground"))
|
||||
self.highlightCheck.setText(_translate("Dialog", "Highlight"))
|
||||
self.boldCheck.setText(_translate("Dialog", "Bold"))
|
||||
self.italicCheck.setText(_translate("Dialog", "Italic"))
|
||||
self.underlineCheck.setText(_translate("Dialog", "Underline"))
|
||||
self.okButton.setText(_translate("Dialog", "OK"))
|
||||
self.cancelButton.setText(_translate("Dialog", "Cancel"))
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
# Form implementation generated from reading ui file 'raw/main.ui'
|
||||
#
|
||||
# Created by: PyQt5 UI code generator 5.9.2
|
||||
# Created by: PyQt5 UI code generator 5.10.1
|
||||
#
|
||||
# WARNING! All changes made in this file will be lost!
|
||||
|
||||
@@ -35,20 +35,6 @@ class Ui_MainWindow(object):
|
||||
self.gridLayout_4.setContentsMargins(0, 0, 0, 0)
|
||||
self.gridLayout_4.setSpacing(0)
|
||||
self.gridLayout_4.setObjectName("gridLayout_4")
|
||||
self.listView = QtWidgets.QListView(self.listPage)
|
||||
self.listView.setFrameShape(QtWidgets.QFrame.NoFrame)
|
||||
self.listView.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
|
||||
self.listView.setProperty("showDropIndicator", False)
|
||||
self.listView.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
|
||||
self.listView.setMovement(QtWidgets.QListView.Static)
|
||||
self.listView.setProperty("isWrapping", True)
|
||||
self.listView.setResizeMode(QtWidgets.QListView.Fixed)
|
||||
self.listView.setLayoutMode(QtWidgets.QListView.SinglePass)
|
||||
self.listView.setViewMode(QtWidgets.QListView.IconMode)
|
||||
self.listView.setUniformItemSizes(True)
|
||||
self.listView.setWordWrap(True)
|
||||
self.listView.setObjectName("listView")
|
||||
self.gridLayout_4.addWidget(self.listView, 0, 0, 1, 1)
|
||||
self.stackedWidget.addWidget(self.listPage)
|
||||
self.tablePage = QtWidgets.QWidget()
|
||||
self.tablePage.setObjectName("tablePage")
|
||||
@@ -56,20 +42,6 @@ class Ui_MainWindow(object):
|
||||
self.gridLayout_3.setContentsMargins(0, 0, 0, 0)
|
||||
self.gridLayout_3.setSpacing(0)
|
||||
self.gridLayout_3.setObjectName("gridLayout_3")
|
||||
self.tableView = QtWidgets.QTableView(self.tablePage)
|
||||
self.tableView.setFrameShape(QtWidgets.QFrame.Box)
|
||||
self.tableView.setFrameShadow(QtWidgets.QFrame.Plain)
|
||||
self.tableView.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustToContentsOnFirstShow)
|
||||
self.tableView.setEditTriggers(QtWidgets.QAbstractItemView.DoubleClicked|QtWidgets.QAbstractItemView.EditKeyPressed|QtWidgets.QAbstractItemView.SelectedClicked)
|
||||
self.tableView.setAlternatingRowColors(True)
|
||||
self.tableView.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
|
||||
self.tableView.setGridStyle(QtCore.Qt.NoPen)
|
||||
self.tableView.setSortingEnabled(True)
|
||||
self.tableView.setWordWrap(False)
|
||||
self.tableView.setObjectName("tableView")
|
||||
self.tableView.horizontalHeader().setVisible(True)
|
||||
self.tableView.verticalHeader().setVisible(False)
|
||||
self.gridLayout_3.addWidget(self.tableView, 0, 0, 1, 1)
|
||||
self.stackedWidget.addWidget(self.tablePage)
|
||||
self.gridLayout_2.addWidget(self.stackedWidget, 0, 0, 1, 1)
|
||||
self.tabWidget.addTab(self.tab, "")
|
@@ -94,26 +94,26 @@ def generate_pie(progress_percent, temp_dir=None):
|
||||
return lSvg
|
||||
|
||||
|
||||
def pixmapper(current_chapter, total_chapters, temp_dir, size):
|
||||
def pixmapper(position_percent, temp_dir, consider_read_at, size):
|
||||
# A current_chapter of -1 implies the files does not exist
|
||||
# A chapter number == Total chapters implies the file is unread
|
||||
return_pixmap = None
|
||||
# position_percent and consider_read_at are expected as a <1 decimal value
|
||||
|
||||
if current_chapter == -1:
|
||||
return_pixmap = None
|
||||
consider_read_at = consider_read_at / 100
|
||||
|
||||
if position_percent == -1:
|
||||
return_pixmap = QtGui.QIcon(':/images/error.svg').pixmap(size)
|
||||
return return_pixmap
|
||||
|
||||
if current_chapter == total_chapters:
|
||||
if position_percent >= consider_read_at: # Consider book read @ this progress
|
||||
return_pixmap = QtGui.QIcon(':/images/checkmark.svg').pixmap(size)
|
||||
else:
|
||||
|
||||
# TODO
|
||||
# See if saving the svg to disk can be avoided
|
||||
# Shift to lines to track progress
|
||||
# Maybe make the alignment a little more uniform across emblems
|
||||
|
||||
progress_percent = int(current_chapter * 100 / total_chapters)
|
||||
generate_pie(progress_percent, temp_dir)
|
||||
generate_pie(int(position_percent * 100), temp_dir)
|
||||
svg_path = os.path.join(temp_dir, 'lector_progress.svg')
|
||||
return_pixmap = QtGui.QIcon(svg_path).pixmap(size - 4) ## The -4 looks more proportional
|
||||
|
8
lector/resources/raw/DarkIcons/about.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<style id="current-color-scheme" type="text/css">
|
||||
.ColorScheme-Text { color:#5c616c; } .ColorScheme-Highlight { color:#5294e2; }
|
||||
</style>
|
||||
</defs>
|
||||
<path style="fill:currentColor" class="ColorScheme-Text" d="M 8 1.0039062 C 4.134 1.0039062 1 4.1380063 1 8.0039062 C 1 11.869906 4.134 15.003906 8 15.003906 C 11.866 15.003906 15 11.869906 15 8.0039062 C 15 4.1380063 11.866 1.0039062 8 1.0039062 z M 8 3.7539062 C 8.69036 3.7539062 9.25 4.3135463 9.25 5.0039062 C 9.25 5.6942662 8.69036 6.2539062 8 6.2539062 C 7.30964 6.2539062 6.75 5.6942662 6.75 5.0039062 C 6.75 4.3135463 7.30964 3.7539062 8 3.7539062 z M 7 7.0039062 L 9 7.0039062 L 9 12.003906 L 7 12.003906 L 7 7.0039062 z" transform="translate(4 4)"/>
|
||||
</svg>
|
After Width: | Height: | Size: 815 B |
Before Width: | Height: | Size: 428 B After Width: | Height: | Size: 428 B |
8
lector/resources/raw/DarkIcons/annotate.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<style id="current-color-scheme" type="text/css">
|
||||
.ColorScheme-Text { color:#5c616c; } .ColorScheme-Highlight { color:#5294e2; }
|
||||
</style>
|
||||
</defs>
|
||||
<path style="fill:currentColor" class="ColorScheme-Text" d="m12.213 1c-0.213 0-0.425 0.083-0.59 0.248l-1.6308 1.6387 3.1208 3.1211 1.639-1.6308c0.33-0.33 0.33-0.8497 0-1.1797l-1.949-1.9493c-0.165-0.165-0.378-0.248-0.59-0.248zm-3.34 3.0078l-7.8808 7.8792-0.00001 3.121h3.1211l0.0078-0.008h10.879v-2h-8.8789l5.8709-5.873-3.119-3.1192z" transform="translate(4 4)"/>
|
||||
</svg>
|
After Width: | Height: | Size: 617 B |
8
lector/resources/raw/DarkIcons/arrow-down.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22">
|
||||
<defs>
|
||||
<style id="current-color-scheme" type="text/css">
|
||||
.ColorScheme-Text { color:#5c616c; } .ColorScheme-Highlight { color:#5294e2; }
|
||||
</style>
|
||||
</defs>
|
||||
<path style="fill:currentColor" class="ColorScheme-Text" d="M 3 6 L 8 11 L 13 6 L 3 6 z" transform="translate(3 3)"/>
|
||||
</svg>
|
After Width: | Height: | Size: 372 B |
8
lector/resources/raw/DarkIcons/arrow-up.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22">
|
||||
<defs>
|
||||
<style id="current-color-scheme" type="text/css">
|
||||
.ColorScheme-Text { color:#5c616c; } .ColorScheme-Highlight { color:#5294e2; }
|
||||
</style>
|
||||
</defs>
|
||||
<path style="fill:currentColor" class="ColorScheme-Text" d="M 8 5 L 3 10 L 13 10 L 8 5 z" transform="translate(3 3)"/>
|
||||
</svg>
|
After Width: | Height: | Size: 373 B |
Before Width: | Height: | Size: 703 B After Width: | Height: | Size: 703 B |
Before Width: | Height: | Size: 546 B After Width: | Height: | Size: 546 B |
Before Width: | Height: | Size: 891 B After Width: | Height: | Size: 891 B |
Before Width: | Height: | Size: 729 B After Width: | Height: | Size: 729 B |
8
lector/resources/raw/DarkIcons/filesaveas.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<style id="current-color-scheme" type="text/css">
|
||||
.ColorScheme-Text { color:#5c616c; } .ColorScheme-Highlight { color:#5294e2; }
|
||||
</style>
|
||||
</defs>
|
||||
<path style="fill:currentColor" class="ColorScheme-Text" d="M 5.9980469 1.0195312 L 5.9980469 7.0195312 L 3.6582031 7.0195312 L 7.9902344 13.324219 L 12.371094 7.0195312 L 9.9980469 7.0195312 L 9.9980469 1.0488281 L 5.9980469 1.0195312 z M 1 14.03125 L 1 16 L 15.005859 16 L 15 14.03125 L 1 14.03125 z" transform="translate(4 4)"/>
|
||||
</svg>
|
After Width: | Height: | Size: 586 B |
Before Width: | Height: | Size: 561 B After Width: | Height: | Size: 561 B |
Before Width: | Height: | Size: 601 B After Width: | Height: | Size: 601 B |
Before Width: | Height: | Size: 565 B After Width: | Height: | Size: 565 B |
Before Width: | Height: | Size: 565 B After Width: | Height: | Size: 565 B |
Before Width: | Height: | Size: 561 B After Width: | Height: | Size: 561 B |
Before Width: | Height: | Size: 565 B After Width: | Height: | Size: 565 B |
Before Width: | Height: | Size: 484 B After Width: | Height: | Size: 484 B |
Before Width: | Height: | Size: 484 B After Width: | Height: | Size: 484 B |
Before Width: | Height: | Size: 694 B After Width: | Height: | Size: 694 B |
8
lector/resources/raw/DarkIcons/invert.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22">
|
||||
<defs>
|
||||
<style id="current-color-scheme" type="text/css">
|
||||
.ColorScheme-Text { color:#444444; } .ColorScheme-Highlight { color:#5294e2; }
|
||||
</style>
|
||||
</defs>
|
||||
<path style="fill:currentColor" class="ColorScheme-Text" d="M 2 1 L 2 15 L 9 15 L 9 13 A 5 5 0 0 1 4 8 A 5 5 0 0 1 9 3 L 9 1 L 2 1 z M 9 3 L 9 13 C 11.7614 13 14 10.7614 14 8 C 14 5.2386 11.7614 3 9 3 z" transform="translate(3 3)"/>
|
||||
</svg>
|
After Width: | Height: | Size: 487 B |
Before Width: | Height: | Size: 642 B After Width: | Height: | Size: 642 B |
61
lector/resources/raw/DarkIcons/manga-mode.svg
Normal file
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="22"
|
||||
height="22"
|
||||
version="1.1"
|
||||
viewBox="0 0 22 22"
|
||||
id="svg7"
|
||||
sodipodi:docname="manga.svg"
|
||||
inkscape:version="0.92.2 2405546, 2018-03-11">
|
||||
<metadata
|
||||
id="metadata11">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1586"
|
||||
inkscape:window-height="856"
|
||||
id="namedview9"
|
||||
showgrid="false"
|
||||
inkscape:zoom="10.727273"
|
||||
inkscape:cx="11"
|
||||
inkscape:cy="11"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg7" />
|
||||
<defs
|
||||
id="defs3">
|
||||
<style
|
||||
id="current-color-scheme"
|
||||
type="text/css">
|
||||
.ColorScheme-Text { color:#6e6e6e; } .ColorScheme-Highlight { color:#5294e2; }
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:#5c616c;fill-opacity:1"
|
||||
class="ColorScheme-Text"
|
||||
d="M 19,11 14,6 V 8 H 8 V 6 l -5,5 5,5 v -2 h 6 v 2 z"
|
||||
id="path5" />
|
||||
</svg>
|
After Width: | Height: | Size: 1.7 KiB |
8
lector/resources/raw/DarkIcons/page-double.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<style id="current-color-scheme" type="text/css">
|
||||
.ColorScheme-Text { color:#5c616c; } .ColorScheme-Highlight { color:#5294e2; }
|
||||
</style>
|
||||
</defs>
|
||||
<path style="fill:currentColor" class="ColorScheme-Text" d="M 2 1 L 2 15 L 14 15 L 14 1 L 2 1 z M 4 3 L 12 3 L 12 13 L 4 13 L 4 3 z" transform="translate(4 4)"/>
|
||||
</svg>
|
After Width: | Height: | Size: 416 B |
Before Width: | Height: | Size: 859 B After Width: | Height: | Size: 859 B |
Before Width: | Height: | Size: 378 B After Width: | Height: | Size: 378 B |
53
lector/resources/raw/DarkIcons/search-case.svg
Normal file
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="16"
|
||||
height="16"
|
||||
version="1.1"
|
||||
id="svg4"
|
||||
sodipodi:docname="search-case.svg"
|
||||
inkscape:version="0.92.2 2405546, 2018-03-11">
|
||||
<metadata
|
||||
id="metadata10">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs8" />
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1480"
|
||||
inkscape:window-height="750"
|
||||
id="namedview6"
|
||||
showgrid="false"
|
||||
inkscape:zoom="14.75"
|
||||
inkscape:cx="-6.5084746"
|
||||
inkscape:cy="8"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg4" />
|
||||
<path
|
||||
style="fill:#5c616c;fill-opacity:1"
|
||||
d="M 4.7890625,2 1,13 h 1.90625 l 0.6796875,-2 h 3.8300781 l 0.6875,2 H 10 L 6.2109375,2 Z M 5.4980469,5.4375 6.6835938,9 H 4.3144531 Z M 11.5,8 v 1 h 3 C 14.715,9 15,9.305 15,9.5 V 10 h -2.5 c -0.46,0 -0.87,0.189375 -1.125,0.484375 C 11.12,10.774375 11,11.14 11,11.5 c 0,0.36 0.135625,0.725625 0.390625,1.015625 C 11.645625,12.805625 12.045,13 12.5,13 H 16 V 9.5 C 16,8.685 15.34,8 14.5,8 Z m 1,3 H 15 v 1 H 12.5 C 12.3,12 12.215625,11.944375 12.140625,11.859375 12.065625,11.774375 12,11.64 12,11.5 12,11.36 12.05,11.225625 12.125,11.140625 12.2,11.060625 12.29,11 12.5,11 Z"
|
||||
id="path2" />
|
||||
</svg>
|
After Width: | Height: | Size: 2.0 KiB |
68
lector/resources/raw/DarkIcons/search-word.svg
Normal file
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="16"
|
||||
height="16"
|
||||
version="1.1"
|
||||
id="svg4"
|
||||
sodipodi:docname="search-word.svg"
|
||||
inkscape:version="0.92.2 2405546, 2018-03-11">
|
||||
<metadata
|
||||
id="metadata10">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs8" />
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1043"
|
||||
id="namedview6"
|
||||
showgrid="false"
|
||||
inkscape:zoom="45.254834"
|
||||
inkscape:cx="6.5123537"
|
||||
inkscape:cy="6.8917559"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg4" />
|
||||
<path
|
||||
style="fill:#5c616c;fill-opacity:1"
|
||||
d="M 3,3 C 1.02036,3 -9.9999927e-7,4.1718311 0,6.6074219 V 8.5 A 1.5,1.5 0 0 0 1.5,10 1.5,1.5 0 0 0 3,8.5 1.5,1.5 0 0 0 1.5097656,7 l -0.00781,-0.4023438 c 0,-0.948207 0.1116447,-1.5979182 0.3378907,-1.9511718 C 2.0660618,4.29323 2.453243,4.0371834 3,4 Z M 7,3 C 5.02036,3 3.999999,4.1718311 4,6.6074219 V 8.5 A 1.5,1.5 0 0 0 5.5,10 1.5,1.5 0 0 0 7,8.5 1.5,1.5 0 0 0 5.5097656,7 l -0.00781,-0.4023438 c 0,-0.948207 0.1116447,-1.5979182 0.3378907,-1.9511718 C 6.0660608,4.29323 6.453243,4.0371834 7,4 Z M 9.5,5 A 1.5,1.5 0 0 0 8,6.5 1.5,1.5 0 0 0 9.490234,8 l 0.00781,0.4023438 c 0,0.948207 -0.111645,1.5979182 -0.337891,1.9511722 C 8.9339389,10.70677 8.5467573,10.962817 8,11 v 1 c 1.97964,0 3.000001,-1.171831 3,-3.6074219 V 6.5 A 1.5,1.5 0 0 0 9.5,5 Z m 4,0 A 1.5,1.5 0 0 0 12,6.5 1.5,1.5 0 0 0 13.490234,8 l 0.0078,0.4023438 c 0,0.948207 -0.111645,1.5979182 -0.337891,1.9511722 C 12.933938,10.70677 12.546757,10.962817 12,11 v 1 c 1.97964,0 3.000001,-1.171831 3,-3.6074219 V 6.5 A 1.5,1.5 0 0 0 13.5,5 Z"
|
||||
id="path2" />
|
||||
<path
|
||||
style="fill:#5c616c;stroke:none;stroke-width:0.06779661;fill-opacity:1"
|
||||
d="M 8.040678,11.489133 V 11.01394 l 0.2542373,-0.0411 C 8.6241141,10.91962 9.0049959,10.657098 9.1919904,10.354534 9.3774432,10.054466 9.5267422,9.2371719 9.5298381,8.5050867 9.5321781,7.9511596 9.5305475,7.9457647 9.3605343,7.9457647 8.6870054,7.9457647 8.0454876,7.2386514 8.0421669,6.4925935 8.0367069,5.2649491 9.4833138,4.5924333 10.437555,5.379 c 0.470567,0.3878808 0.518377,0.5947737 0.518377,2.2432227 0,0.9924588 -0.03143,1.6284257 -0.09756,1.9740504 -0.267079,1.3958959 -1.1175811,2.2244799 -2.3939662,2.3322709 l -0.4237288,0.03579 z"
|
||||
id="path4524"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
style="fill:#5c616c;stroke:none;stroke-width:0.00390625;fill-opacity:1"
|
||||
d="m 9.7117131,11.513295 c 0.00204,-0.0018 0.023926,-0.01759 0.048633,-0.03507 0.1798996,-0.127248 0.3444879,-0.28336 0.4858319,-0.460812 0.252616,-0.317149 0.444866,-0.723077 0.559095,-1.1805105 0.06615,-0.2648918 0.09348,-0.4533196 0.116987,-0.8066406 C 10.94809,8.6420545 10.9575,8.1721993 10.95441,7.4247931 10.952,6.840388 10.947836,6.672371 10.92997,6.438465 10.924141,6.3621478 10.9106,6.2310896 10.906461,6.2109259 l -0.0022,-0.010742 h 0.0313 c 0.0366,0 0.0324,-0.00607 0.0433,0.0625 0.01847,0.1161553 0.01782,0.070812 0.01782,1.25 0,1.1705155 -1.65e-4,1.1838055 -0.01779,1.4296875 -0.05607,0.7822203 -0.232739,1.4031726 -0.538111,1.8913516 -0.157745,0.252177 -0.36026,0.471333 -0.5979529,0.647089 l -0.048386,0.03578 h -0.043215 c -0.023798,0 -0.041548,-0.0015 -0.039504,-0.0033 z"
|
||||
id="path4526"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
style="fill:#5c616c;stroke:none;stroke-width:0.00390625;fill-opacity:1"
|
||||
d="m 8.0037065,11.500359 v -0.499846 l 0.038086,-0.0025 c 0.052532,-0.0035 0.1567951,-0.0193 0.2234149,-0.03389 C 8.640889,10.881833 8.9448125,10.678717 9.1476483,10.374368 9.3523256,10.067256 9.4641833,9.5521406 9.492672,8.7855008 L 9.497502,8.655618 H 9.512387 9.527272 L 9.524992,8.705423 C 9.5049185,9.1437939 9.4502274,9.5471986 9.3653315,9.8830268 9.3036893,10.126869 9.2445481,10.277176 9.1635619,10.395823 9.0101537,10.620571 8.748788,10.822299 8.4830209,10.92108 c -0.078653,0.02923 -0.1439508,0.04431 -0.30158,0.06961 l -0.140625,0.02258 -9.922e-4,0.475456 c -9.556e-4,0.457983 -7.301e-4,0.475456 0.00614,0.475456 0.012211,0 0.421374,-0.03493 0.483138,-0.04125 0.3949367,-0.04038 0.7679272,-0.159625 1.078125,-0.344663 0.023633,-0.0141 0.0556,-0.03409 0.071038,-0.04442 l 0.028069,-0.01879 0.04029,0.003 0.04029,0.003 -0.046244,0.03035 c -0.2869093,0.188322 -0.6383031,0.321037 -1.0300398,0.389028 -0.189836,0.03295 -0.3718111,0.04969 -0.6219586,0.0572 l -0.084961,0.0026 z"
|
||||
id="path4528"
|
||||
inkscape:connector-curvature="0" />
|
||||
</svg>
|
After Width: | Height: | Size: 5.2 KiB |
8
lector/resources/raw/DarkIcons/search.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22">
|
||||
<defs>
|
||||
<style id="current-color-scheme" type="text/css">
|
||||
.ColorScheme-Text { color:#5c616c; } .ColorScheme-Highlight { color:#5294e2; }
|
||||
</style>
|
||||
</defs>
|
||||
<path style="fill:currentColor" class="ColorScheme-Text" d="M 6.4902344 0.99609375 C 3.4613344 0.99609375 0.99023438 3.4706937 0.99023438 6.4960938 C 0.99023438 9.5214938 3.4613344 11.996094 6.4902344 11.996094 C 7.6422344 11.996094 8.7279444 11.638254 9.6152344 11.027344 L 13.302734 14.714844 A 1.0055 1.0055 0 1 0 14.708984 13.277344 L 11.021484 9.5898438 C 11.632274 8.7038438 12.021484 7.6459938 12.021484 6.4960938 C 12.021484 3.4706937 9.5190344 0.99609375 6.4902344 0.99609375 z M 6.4902344 2.9960938 C 8.4376344 2.9960938 9.9902344 4.5508938 9.9902344 6.4960938 C 9.9902344 8.4411937 8.4376344 9.9960938 6.4902344 9.9960938 C 4.5428344 9.9960938 2.9902344 8.4411937 2.9902344 6.4960938 C 2.9902344 4.5508938 4.5428344 2.9960938 6.4902344 2.9960938 z" transform="translate(3 3)"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.0 KiB |
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
8
lector/resources/raw/DarkIcons/switches.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<style id="current-color-scheme" type="text/css">
|
||||
.ColorScheme-Text { color:#5c616c; } .ColorScheme-Highlight { color:#5294e2; }
|
||||
</style>
|
||||
</defs>
|
||||
<path style="fill:currentColor" class="ColorScheme-Text" d="M 2 0 C 0.892 0 0 0.892 0 2 L 0 14 C 0 15.108 0.892 16 2 16 L 14 16 C 15.108 16 16 15.108 16 14 L 16 2 C 16 0.892 15.108 0 14 0 L 2 0 z M 3.7148438 2 L 12.285156 2 C 13.235156 2 14 2.7651437 14 3.7148438 L 14 12.285156 C 14 13.235156 13.235156 14 12.285156 14 L 3.7148438 14 C 2.7651438 14 2 13.235156 2 12.285156 L 2 3.7148438 C 2 2.7651438 2.7651437 2 3.7148438 2 z M 6.7402344 3 L 6.6289062 4.3164062 A 3.964 3.9286 0 0 0 5.4707031 4.9804688 L 4.2617188 4.4179688 L 3.0019531 6.5820312 L 4.0976562 7.3378906 A 3.964 3.9286 0 0 0 4.0371094 8 A 3.964 3.9286 0 0 0 4.0957031 8.6660156 L 3.0019531 9.4179688 L 4.2617188 11.582031 L 5.4667969 11.019531 A 3.964 3.9286 0 0 0 6.6289062 11.679688 L 6.7402344 13 L 9.2617188 13 L 9.3730469 11.683594 A 3.964 3.9286 0 0 0 10.53125 11.019531 L 11.740234 11.582031 L 13.001953 9.4179688 L 11.904297 8.6621094 A 3.964 3.9286 0 0 0 11.964844 8 A 3.964 3.9286 0 0 0 11.908203 7.3339844 L 13.001953 6.5820312 L 11.740234 4.4179688 L 10.535156 4.9804688 A 3.964 3.9286 0 0 0 9.3730469 4.3203125 L 9.2617188 3 L 6.7402344 3 z M 8.0019531 6.5722656 A 1.4414 1.4286 0 0 1 9.4433594 8 A 1.4414 1.4286 0 0 1 8.0019531 9.4277344 A 1.4414 1.4286 0 0 1 6.5605469 8 A 1.4414 1.4286 0 0 1 8.0019531 6.5722656 z" transform="translate(4 4)"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 501 B After Width: | Height: | Size: 501 B |
8
lector/resources/raw/DarkIcons/tableofcontents.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22">
|
||||
<defs>
|
||||
<style id="current-color-scheme" type="text/css">
|
||||
.ColorScheme-Text { color:#5c616c; } .ColorScheme-Highlight { color:#5294e2; }
|
||||
</style>
|
||||
</defs>
|
||||
<path style="fill:currentColor" class="ColorScheme-Text" d="M 1 3.0039062 L 1 5.0039062 L 3 5.0039062 L 3 3.0039062 L 1 3.0039062 z M 5 3.0039062 L 5 5.0039062 L 15 5.0039062 L 15 3.0039062 L 5 3.0039062 z M 1 7.0039062 L 1 9.0039062 L 3 9.0039062 L 3 7.0039062 L 1 7.0039062 z M 5 7.0039062 L 5 9.0039062 L 15 9.0039062 L 15 7.0039062 L 5 7.0039062 z M 1 11.003906 L 1 13.003906 L 3 13.003906 L 3 11.003906 L 1 11.003906 z M 5 11.003906 L 5 13.003906 L 15 13.003906 L 15 11.003906 L 5 11.003906 z" transform="translate(3 3)"/>
|
||||
</svg>
|
After Width: | Height: | Size: 782 B |
Before Width: | Height: | Size: 781 B After Width: | Height: | Size: 781 B |
Before Width: | Height: | Size: 916 B After Width: | Height: | Size: 916 B |
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
8
lector/resources/raw/DarkIcons/web-browser.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<style id="current-color-scheme" type="text/css">
|
||||
.ColorScheme-Text { color:#5c616c; } .ColorScheme-Highlight { color:#5294e2; }
|
||||
</style>
|
||||
</defs>
|
||||
<path style="fill:currentColor" class="ColorScheme-Text" d="M 8 0.99609375 C 4.134 0.99609375 1 4.1300937 1 7.9960938 C 1 11.862094 4.134 14.996094 8 14.996094 C 11.866 14.996094 15 11.862094 15 7.9960938 C 15 4.1300937 11.866 0.99609375 8 0.99609375 z M 7.5 2.9335938 C 7.5669 2.9265937 7.65125 2.9375937 7.71875 2.9335938 C 7.72675 2.9655938 7.67005 3.0794638 7.59375 3.2460938 C 7.10789 4.3074937 7.08033 5.5504437 7.53125 6.2148438 C 7.61285 6.3353038 7.6875 6.4499437 7.6875 6.4648438 C 7.6875 6.4797438 7.5995 6.4960938 7.5 6.4960938 C 7.26642 6.4960938 7.04538 6.3537238 6.59375 5.9960938 C 6.39312 5.8372237 6.1323 5.7037938 6.03125 5.6835938 C 5.87257 5.6518937 5.83028 5.6657938 5.625 5.8710938 C 5.43401 6.0620537 5.375 6.1650237 5.375 6.3398438 C 5.375 7.0027837 6.16208 7.5297437 7.625 7.8398438 C 9.6117 8.2609137 10.10145 8.6389138 10.15625 9.6835938 C 10.22505 10.993594 9.5276 11.981394 8 12.746094 C 7.81767 12.837394 7.7015 12.872844 7.625 12.902344 C 7.5911 12.899344 7.56505 12.905344 7.53125 12.902344 C 7.51825 12.861844 7.5 12.767884 7.5 12.589844 C 7.5 11.894064 7.22575 11.177844 6.8125 10.777344 C 6.70157 10.669824 6.39098 10.441994 6.125 10.277344 C 5.85903 10.112704 5.59105 9.9214438 5.53125 9.8398438 C 5.43215 9.7044337 5.42386 9.6212437 5.5 9.3710938 C 5.63876 8.9142237 5.80392 8.6597637 6.125 8.3710938 C 6.29333 8.2197537 6.46271 8.0928437 6.5 8.0898438 C 6.5373 8.0868438 6.28485 8.0110437 5.90625 7.9335938 C 5.52767 7.8559938 4.97383 7.6934738 4.6875 7.5898438 C 4.16392 7.4003938 3.457 7.0026837 3.1875 6.7148438 C 3.1761 6.7026437 3.16615 6.6943938 3.15625 6.6835938 C 3.54238 5.1454938 4.626 3.8848438 6.0625 3.2773438 C 6.36307 3.1502138 6.67292 3.0629938 7 2.9960938 C 7.16292 2.9627938 7.33178 2.9506937 7.5 2.9335938 z M 12.1875 5.2773438 C 12.30495 5.3499437 12.74841 6.3093438 12.875 6.7773438 C 13.03844 7.3815337 13.02661 8.4271437 12.875 9.0273438 C 12.8173 9.2557838 12.74335 9.4694937 12.71875 9.4960938 C 12.69415 9.5226938 12.60494 9.3695637 12.5 9.1835938 C 12.39505 8.9976538 12.05984 8.6025437 11.78125 8.3085938 C 10.97711 7.4600637 10.85066 7.0170437 11.1875 6.3398438 C 11.35737 5.9983538 12.0966 5.2212438 12.1875 5.2773438 z" transform="translate(4 4)"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 790 B After Width: | Height: | Size: 790 B |
Before Width: | Height: | Size: 564 B After Width: | Height: | Size: 564 B |
Before Width: | Height: | Size: 540 B After Width: | Height: | Size: 540 B |