mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Sync to trunk
This commit is contained in:
commit
94cf3eae9b
@ -441,15 +441,19 @@ def entity_to_unicode(match, exceptions=[], encoding='cp1252'):
|
||||
|
||||
if isosx:
|
||||
fdir = os.path.expanduser('~/.fonts')
|
||||
if not os.path.exists(fdir):
|
||||
os.makedirs(fdir)
|
||||
if not os.path.exists(os.path.join(fdir, 'LiberationSans_Regular.ttf')):
|
||||
from calibre.ebooks.lrf.fonts.liberation import __all__ as fonts
|
||||
for font in fonts:
|
||||
l = {}
|
||||
exec 'from calibre.ebooks.lrf.fonts.liberation.'+font+' import font_data' in l
|
||||
open(os.path.join(fdir, font+'.ttf'), 'wb').write(l['font_data'])
|
||||
|
||||
try:
|
||||
if not os.path.exists(fdir):
|
||||
os.makedirs(fdir)
|
||||
if not os.path.exists(os.path.join(fdir, 'LiberationSans_Regular.ttf')):
|
||||
from calibre.ebooks.lrf.fonts.liberation import __all__ as fonts
|
||||
for font in fonts:
|
||||
l = {}
|
||||
exec 'from calibre.ebooks.lrf.fonts.liberation.'+font+' import font_data' in l
|
||||
open(os.path.join(fdir, font+'.ttf'), 'wb').write(l['font_data'])
|
||||
except:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Migrate from QSettings based config system
|
||||
from calibre.utils.config import migrate
|
||||
migrate()
|
||||
|
@ -2,7 +2,7 @@ __license__ = 'GPL v3'
|
||||
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
|
||||
__docformat__ = 'restructuredtext en'
|
||||
__appname__ = 'calibre'
|
||||
__version__ = '0.4.131'
|
||||
__version__ = '0.4.132'
|
||||
__author__ = "Kovid Goyal <kovid@kovidgoyal.net>"
|
||||
'''
|
||||
Various run time constants.
|
||||
|
@ -181,11 +181,11 @@ class Device(_Device):
|
||||
elif self.windows_match_device(str(drive.PNPDeviceID), self.WINDOWS_CARD_MEM):
|
||||
drives['card'] = self.windows_get_drive_prefix(drive)
|
||||
|
||||
if 'main' and 'card' in drives.keys():
|
||||
if 'main' in drives.keys() and 'card' in drives.keys():
|
||||
break
|
||||
|
||||
self._main_prefix = drives.get('main', None)
|
||||
self._card_prefix = drives.get('card', None)
|
||||
self._main_prefix = drives.get('main')
|
||||
self._card_prefix = drives.get('card')
|
||||
|
||||
if not self._main_prefix:
|
||||
raise DeviceError(_('Unable to detect the %s disk drive. Try rebooting.') % self.__class__.__name__)
|
||||
|
@ -142,6 +142,8 @@ to auto-generate a Table of Contents.
|
||||
help=_('XPath expression that specifies all tags that should be added to the Table of Contents at level one. If this is specified, it takes precedence over other forms of auto-detection.'))
|
||||
toc('level2_toc', ['--level2-toc'], default=None,
|
||||
help=_('XPath expression that specifies all tags that should be added to the Table of Contents at level two. Each entry is added under the previous level one entry.'))
|
||||
toc('level3_toc', ['--level3-toc'], default=None,
|
||||
help=_('XPath expression that specifies all tags that should be added to the Table of Contents at level three. Each entry is added under the previous level two entry.'))
|
||||
toc('from_ncx', ['--from-ncx'], default=None,
|
||||
help=_('Path to a .ncx file that contains the table of contents to use for this ebook. The NCX file should contain links relative to the directory it is placed in. See http://www.niso.org/workrooms/daisy/Z39-86-2005.html#NCX for an overview of the NCX format.'))
|
||||
toc('use_auto_toc', ['--use-auto-toc'], default=False,
|
||||
|
@ -377,16 +377,13 @@ def convert(htmlfile, opts, notification=None, create_epub=True,
|
||||
mi = merge_metadata(htmlfile, opf, opts)
|
||||
opts.chapter = XPath(opts.chapter,
|
||||
namespaces={'re':'http://exslt.org/regular-expressions'})
|
||||
if opts.level1_toc:
|
||||
opts.level1_toc = XPath(opts.level1_toc,
|
||||
namespaces={'re':'http://exslt.org/regular-expressions'})
|
||||
else:
|
||||
opts.level1_toc = None
|
||||
if opts.level2_toc:
|
||||
opts.level2_toc = XPath(opts.level2_toc,
|
||||
namespaces={'re':'http://exslt.org/regular-expressions'})
|
||||
else:
|
||||
opts.level2_toc = None
|
||||
for x in (1, 2, 3):
|
||||
attr = 'level%d_toc'%x
|
||||
if getattr(opts, attr):
|
||||
setattr(opts, attr, XPath(getattr(opts, attr),
|
||||
namespaces={'re':'http://exslt.org/regular-expressions'}))
|
||||
else:
|
||||
setattr(opts, attr, None)
|
||||
|
||||
with TemporaryDirectory(suffix='_html2epub', keep=opts.keep_intermediate) as tdir:
|
||||
if opts.keep_intermediate:
|
||||
|
@ -558,31 +558,22 @@ class Processor(Parser):
|
||||
|
||||
def detect_chapters(self):
|
||||
self.detected_chapters = self.opts.chapter(self.root)
|
||||
chapter_mark = self.opts.chapter_mark
|
||||
page_break_before = 'display: block; page-break-before: always'
|
||||
page_break_after = 'display: block; page-break-after: always'
|
||||
for elem in self.detected_chapters:
|
||||
text = u' '.join([t.strip() for t in elem.xpath('descendant::text()')])
|
||||
self.log_info('\tDetected chapter: %s', text[:50])
|
||||
if self.opts.chapter_mark != 'none':
|
||||
hr = etree.Element('hr')
|
||||
if elem.getprevious() is None:
|
||||
elem.getparent()[:0] = [hr]
|
||||
elif elem.getparent() is not None:
|
||||
insert = None
|
||||
for i, c in enumerate(elem.getparent()):
|
||||
if c is elem:
|
||||
insert = i
|
||||
break
|
||||
elem.getparent()[insert:insert] = [hr]
|
||||
if self.opts.chapter_mark != 'rule':
|
||||
hr.set('style', 'width:0pt;page-break-before:always')
|
||||
if self.opts.chapter_mark == 'both':
|
||||
hr2 = etree.Element('hr')
|
||||
hr2.tail = u'\u00a0'
|
||||
p = hr.getparent()
|
||||
i = p.index(hr)
|
||||
p[i:i] = [hr2]
|
||||
|
||||
|
||||
|
||||
if chapter_mark == 'none':
|
||||
continue
|
||||
elif chapter_mark == 'rule':
|
||||
mark = etree.Element('hr')
|
||||
elif chapter_mark == 'pagebreak':
|
||||
mark = etree.Element('div', style=page_break_after)
|
||||
else: # chapter_mark == 'both':
|
||||
mark = etree.Element('hr', style=page_break_before)
|
||||
elem.addprevious(mark)
|
||||
|
||||
def save(self):
|
||||
style_path = os.path.splitext(os.path.basename(self.save_path()))[0]
|
||||
for i, sheet in enumerate([self.stylesheet, self.font_css, self.override_css]):
|
||||
@ -647,6 +638,7 @@ class Processor(Parser):
|
||||
added[elem] = add_item(_href, frag, text, toc, type='chapter')
|
||||
add_item(_href, frag, 'Top', added[elem], type='chapter')
|
||||
if self.opts.level2_toc is not None:
|
||||
added2 = {}
|
||||
level2 = list(self.opts.level2_toc(self.root))
|
||||
for elem in level2:
|
||||
level1 = None
|
||||
@ -657,7 +649,21 @@ class Processor(Parser):
|
||||
text, _href, frag = elem_to_link(elem, href, counter)
|
||||
counter += 1
|
||||
if text:
|
||||
added2[elem] = \
|
||||
add_item(_href, frag, text, level1, type='chapter')
|
||||
if self.opts.level3_toc is not None:
|
||||
level3 = list(self.opts.level3_toc(self.root))
|
||||
for elem in level3:
|
||||
level2 = None
|
||||
for item in self.root.iterdescendants():
|
||||
if item in added2.keys():
|
||||
level2 = added2[item]
|
||||
elif item == elem and level2 is not None:
|
||||
text, _href, frag = elem_to_link(elem, href, counter)
|
||||
counter += 1
|
||||
if text:
|
||||
add_item(_href, frag, text, level2, type='chapter')
|
||||
|
||||
|
||||
if len(toc) > 0:
|
||||
return
|
||||
@ -892,7 +898,7 @@ def config(defaults=None, config_name='html',
|
||||
metadata('title', ['-t', '--title'], default=None,
|
||||
help=_('Set the title. Default is to autodetect.'))
|
||||
metadata('authors', ['-a', '--authors'], default=None,
|
||||
help=_('The author(s) of the ebook, as a comma separated list.'))
|
||||
help=_('The author(s) of the ebook, as a & separated list.'))
|
||||
metadata('tags', ['--subjects'], default=None,
|
||||
help=_('The subject(s) of this book, as a comma separated list.'))
|
||||
metadata('publisher', ['--publisher'], default=None,
|
||||
@ -988,7 +994,9 @@ def merge_metadata(htmlfile, opf, opts):
|
||||
val = getattr(opts, attr, None)
|
||||
if val is None or val == _('Unknown') or val == [_('Unknown')]:
|
||||
continue
|
||||
if attr in ('authors', 'tags'):
|
||||
if attr =='authors':
|
||||
val = [i.strip() for i in val.split('&') if i.strip()]
|
||||
elif attr == 'tags':
|
||||
val = [i.strip() for i in val.split(',') if i.strip()]
|
||||
setattr(mi, attr, val)
|
||||
|
||||
@ -997,7 +1005,10 @@ def merge_metadata(htmlfile, opf, opts):
|
||||
mi.cover = os.path.abspath(cover)
|
||||
|
||||
if not mi.title:
|
||||
mi.title = os.path.splitext(os.path.basename(htmlfile))[0]
|
||||
if htmlfile:
|
||||
mi.title = os.path.splitext(os.path.basename(htmlfile))[0]
|
||||
else:
|
||||
mi.title = _('Unknown')
|
||||
if not mi.authors:
|
||||
mi.authors = [_('Unknown')]
|
||||
return mi
|
||||
|
@ -170,7 +170,7 @@ def generate_html(rtfpath, tdir):
|
||||
f.write(res)
|
||||
f.close()
|
||||
try:
|
||||
mi = get_metadata(open(rtfpath, 'rb'))
|
||||
mi = get_metadata(open(rtfpath, 'rb'), 'rtf')
|
||||
except:
|
||||
mi = MetaInformation(None, None)
|
||||
if not mi.title:
|
||||
|
185
src/calibre/ebooks/metadata/cli.py
Normal file
185
src/calibre/ebooks/metadata/cli.py
Normal file
@ -0,0 +1,185 @@
|
||||
from __future__ import with_statement
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2009, Kovid Goyal kovid@kovidgoyal.net'
|
||||
__docformat__ = 'restructuredtext en'
|
||||
|
||||
'''
|
||||
ebook-meta
|
||||
'''
|
||||
USAGE='%%prog ebook_file [' + _('options') + ']\n' + \
|
||||
_('''
|
||||
Read/Write metadata from/to ebook files.
|
||||
|
||||
Supported formats for reading metadata: %s
|
||||
|
||||
Supported formats for writing metadata: %s
|
||||
|
||||
Different file types support different kinds of metadata. If you try to set
|
||||
some metadata on a file type that does not support it, the metadata will be
|
||||
silently ignored.
|
||||
''')
|
||||
|
||||
import sys, os
|
||||
|
||||
from calibre.utils.config import StringConfig
|
||||
from calibre.customize.ui import metadata_readers, metadata_writers
|
||||
from calibre.ebooks.metadata.meta import get_metadata, set_metadata
|
||||
from calibre.ebooks.metadata import string_to_authors, authors_to_sort_string, \
|
||||
title_sort, MetaInformation
|
||||
from calibre import prints
|
||||
|
||||
def config():
|
||||
c = StringConfig('')
|
||||
c.add_opt('title', ['-t', '--title'],
|
||||
help=_('Set the title.'))
|
||||
c.add_opt('authors', ['-a', '--authors'],
|
||||
help=_('Set the authors. Multiple authors should be separated '
|
||||
'by the & character. Author names should be in the order '
|
||||
'Firstname Lastname.'))
|
||||
c.add_opt('title_sort', ['--title-sort'],
|
||||
help=_('The version of the title to be used for sorting. '
|
||||
'If unspecified, and the title is specified, it will '
|
||||
'be auto-generated from the title.'))
|
||||
c.add_opt('author_sort', ['--author-sort'],
|
||||
help=_('String to be used when sorting by author. '
|
||||
'If unspecified, and the author(s) are specified, it will '
|
||||
'be auto-generated from the author(s).'))
|
||||
c.add_opt('cover', ['--cover'],
|
||||
help=_('Set the cover to the specified file.'))
|
||||
c.add_opt('comments', ['-c', '--comments'],
|
||||
help=_('Set the ebook description.'))
|
||||
c.add_opt('publisher', ['-p', '--publisher'],
|
||||
help=_('Set the ebook publisher.'))
|
||||
c.add_opt('series', ['-s', '--series'],
|
||||
help=_('Set the series this ebook belongs to.'))
|
||||
c.add_opt('series_index', ['-i', '--index'],
|
||||
help=_('Set the index of the book in this series.'))
|
||||
c.add_opt('rating', ['-r', '--rating'],
|
||||
help=_('Set the rating. Should be a number between 1 and 5.'))
|
||||
c.add_opt('isbn', ['--isbn'],
|
||||
help=_('Set the ISBN of the book.'))
|
||||
c.add_opt('tags', ['--tags'],
|
||||
help=_('Set the tags for the book. Should be a comma separated list.'))
|
||||
c.add_opt('book_producer', ['-k', '--book-producer'],
|
||||
help=_('Set the book producer.'))
|
||||
c.add_opt('language', ['-l', '--language'],
|
||||
help=_('Set the language.'))
|
||||
|
||||
c.add_opt('get_cover', ['--get-cover'],
|
||||
help=_('Get the cover from the ebook and save it at as the '
|
||||
'specified file.'))
|
||||
c.add_opt('to_opf', ['--to-opf'],
|
||||
help=_('Specify the name of an OPF file. The metadata will '
|
||||
'be written to the OPF file.'))
|
||||
c.add_opt('from_opf', ['--from-opf'],
|
||||
help=_('Read metadata from the specified OPF file and use it to '
|
||||
'set metadata in the ebook. Metadata specified on the'
|
||||
'command line will override metadata read from the OPF file'))
|
||||
return c
|
||||
|
||||
def filetypes():
|
||||
readers = set([])
|
||||
for r in metadata_readers():
|
||||
readers = readers.union(set(r.file_types))
|
||||
return readers
|
||||
|
||||
def option_parser():
|
||||
writers = set([])
|
||||
for w in metadata_writers():
|
||||
writers = writers.union(set(w.file_types))
|
||||
return config().option_parser(USAGE%(list(filetypes()), list(writers)))
|
||||
|
||||
def do_set_metadata(opts, mi, stream, stream_type):
|
||||
mi = MetaInformation(mi)
|
||||
for x in ('guide', 'toc', 'manifest', 'spine'):
|
||||
setattr(mi, x, None)
|
||||
|
||||
from_opf = getattr(opts, 'from_opf', None)
|
||||
if from_opf is not None:
|
||||
from calibre.ebooks.metadata.opf2 import OPF
|
||||
opf_mi = MetaInformation(OPF(open(from_opf, 'rb')))
|
||||
mi.smart_update(opf_mi)
|
||||
|
||||
for pref in config().option_set.preferences:
|
||||
if pref.name in ('to_opf', 'from_opf', 'authors', 'title_sort',
|
||||
'author_sort', 'get_cover', 'cover', 'tags'):
|
||||
continue
|
||||
val = getattr(opts, pref.name, None)
|
||||
if val is not None:
|
||||
setattr(mi, pref.name, getattr())
|
||||
|
||||
if getattr(opts, 'authors', None) is not None:
|
||||
mi.authors = string_to_authors(opts.authors)
|
||||
mi.author_sort = authors_to_sort_string(mi.authors)
|
||||
if getattr(opts, 'author_sort', None) is not None:
|
||||
mi.author_sort = opts.author_sort
|
||||
if getattr(opts, 'title_sort', None) is not None:
|
||||
mi.title_sort = opts.title_sort
|
||||
elif getattr(opts, 'title', None) is not None:
|
||||
mi.title_sort = title_sort(opts.title)
|
||||
if getattr(opts, 'tags', None) is not None:
|
||||
mi.tags = [t.strip() for t in opts.tags.split(',')]
|
||||
|
||||
if getattr(opts, 'cover', None) is not None:
|
||||
ext = os.path.splitext(opts.cover)[1].replace('.', '').upper()
|
||||
mi.cover_data = (ext, open(opts.cover, 'rb').read())
|
||||
|
||||
set_metadata(stream, mi, stream_type)
|
||||
|
||||
|
||||
def main(args=sys.argv):
|
||||
parser = option_parser()
|
||||
opts, args = parser.parse_args(args)
|
||||
if len(args) < 2:
|
||||
parser.print_help()
|
||||
prints(_('No file specified'), file=sys.stderr)
|
||||
return 1
|
||||
path = args[1]
|
||||
stream = open(path, 'r+b')
|
||||
stream_type = os.path.splitext(path)[1].replace('.', '').lower()
|
||||
|
||||
trying_to_set = False
|
||||
for pref in config().option_set.preferences:
|
||||
if pref.name in ('to_opf', 'get_cover'):
|
||||
continue
|
||||
if getattr(opts, pref.name) is not None:
|
||||
trying_to_set = True
|
||||
break
|
||||
mi = get_metadata(stream, stream_type)
|
||||
if trying_to_set:
|
||||
prints(_('Original metadata')+'::')
|
||||
metadata = unicode(mi)
|
||||
if trying_to_set:
|
||||
metadata = '\t'+'\n\t'.join(metadata.split('\n'))
|
||||
prints(metadata)
|
||||
|
||||
if trying_to_set:
|
||||
stream.seek(0)
|
||||
do_set_metadata(opts, mi, stream, stream_type)
|
||||
stream.seek(0)
|
||||
stream.flush()
|
||||
mi = get_metadata(stream, stream_type)
|
||||
prints(_('Changed metadata')+'::')
|
||||
metadata = unicode(mi)
|
||||
metadata = '\t'+'\n\t'.join(metadata.split('\n'))
|
||||
prints(metadata)
|
||||
|
||||
if opts.to_opf is not None:
|
||||
from calibre.ebooks.metadata.opf2 import OPFCreator
|
||||
opf = OPFCreator(os.getcwdu(), mi)
|
||||
with open(opts.opf, 'wb') as f:
|
||||
opf.render(f)
|
||||
prints(_('OPF created in'), opts.opf)
|
||||
|
||||
if opts.get_cover is not None:
|
||||
if mi.cover_data and mi.cover_data[1]:
|
||||
with open(opts.get_cover, 'wb') as f:
|
||||
f.write(mi.cover_data[1])
|
||||
prints(_('Cover saved to'), f.name)
|
||||
else:
|
||||
prints(_('No cover found'), file=sys.stderr)
|
||||
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
@ -106,9 +106,11 @@ class CoverRenderer(QObject):
|
||||
WIDTH = 600
|
||||
HEIGHT = 800
|
||||
|
||||
def __init__(self, url, size, loop):
|
||||
def __init__(self, path):
|
||||
if QApplication.instance() is None:
|
||||
QApplication([])
|
||||
QObject.__init__(self)
|
||||
self.loop = loop
|
||||
self.loop = QEventLoop()
|
||||
self.page = QWebPage()
|
||||
pal = self.page.palette()
|
||||
pal.setBrush(QPalette.Background, Qt.white)
|
||||
@ -117,33 +119,43 @@ class CoverRenderer(QObject):
|
||||
self.page.mainFrame().setScrollBarPolicy(Qt.Vertical, Qt.ScrollBarAlwaysOff)
|
||||
self.page.mainFrame().setScrollBarPolicy(Qt.Horizontal, Qt.ScrollBarAlwaysOff)
|
||||
QObject.connect(self.page, SIGNAL('loadFinished(bool)'), self.render_html)
|
||||
self.image_data = None
|
||||
self._image_data = None
|
||||
self.rendered = False
|
||||
url = QUrl.fromLocalFile(os.path.normpath(path))
|
||||
self.page.mainFrame().load(url)
|
||||
|
||||
def render_html(self, ok):
|
||||
self.rendered = True
|
||||
try:
|
||||
if not ok:
|
||||
self.rendered = True
|
||||
return
|
||||
#size = self.page.mainFrame().contentsSize()
|
||||
#width, height = fit_image(size.width(), size.height(), self.WIDTH, self.HEIGHT)[1:]
|
||||
#self.page.setViewportSize(QSize(width, height))
|
||||
image = QImage(self.page.viewportSize(), QImage.Format_ARGB32)
|
||||
image.setDotsPerMeterX(96*(100/2.54))
|
||||
image.setDotsPerMeterY(96*(100/2.54))
|
||||
painter = QPainter(image)
|
||||
self.page.mainFrame().render(painter)
|
||||
painter.end()
|
||||
|
||||
ba = QByteArray()
|
||||
buf = QBuffer(ba)
|
||||
buf.open(QBuffer.WriteOnly)
|
||||
image.save(buf, 'JPEG')
|
||||
self.image_data = str(ba.data())
|
||||
self._image_data = str(ba.data())
|
||||
finally:
|
||||
self.loop.exit(0)
|
||||
|
||||
self.rendered = True
|
||||
|
||||
def image_data():
|
||||
def fget(self):
|
||||
if not self.rendered:
|
||||
self.loop.exec_()
|
||||
count = 0
|
||||
while count < 50 and not self.rendered:
|
||||
time.sleep(0.1)
|
||||
count += 1
|
||||
return self._image_data
|
||||
return property(fget=fget)
|
||||
image_data = image_data()
|
||||
|
||||
|
||||
def get_cover(opf, opf_path, stream):
|
||||
spine = list(opf.spine_items())
|
||||
@ -155,20 +167,11 @@ def get_cover(opf, opf_path, stream):
|
||||
stream.seek(0)
|
||||
ZipFile(stream).extractall()
|
||||
opf_path = opf_path.replace('/', os.sep)
|
||||
cpage = os.path.join(tdir, os.path.dirname(opf_path), *cpage.split('/'))
|
||||
cpage = os.path.join(tdir, os.path.dirname(opf_path), cpage)
|
||||
if not os.path.exists(cpage):
|
||||
return
|
||||
if QApplication.instance() is None:
|
||||
QApplication([])
|
||||
url = QUrl.fromLocalFile(cpage)
|
||||
loop = QEventLoop()
|
||||
cr = CoverRenderer(url, os.stat(cpage).st_size, loop)
|
||||
loop.exec_()
|
||||
count = 0
|
||||
while count < 50 and not cr.rendered:
|
||||
time.sleep(0.1)
|
||||
count += 1
|
||||
return cr.image_data
|
||||
cr = CoverRenderer(cpage)
|
||||
return cr.image_data
|
||||
|
||||
def get_metadata(stream, extract_cover=True):
|
||||
""" Return metadata as a :class:`MetaInformation` object """
|
||||
|
@ -5,7 +5,7 @@
|
||||
unique-identifier="${__appname__}_id"
|
||||
|
||||
>
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xmlns:calibre="http://calibre.kovidgoyal.net/2009/metadata">
|
||||
<dc:title py:with="attrs={'opf:files-as':mi.title_sort}" py:attrs="attrs">${mi.title}</dc:title>
|
||||
<dc:creator opf:role="aut" py:for="i, author in enumerate(mi.authors)" py:attrs="{'opf:file-as':mi.author_sort} if mi.author_sort and i == 0 else {}">${author}</dc:creator>
|
||||
<dc:contributor opf:role="bkp" py:with="attrs={'opf:files-as':__appname__}" py:attrs="attrs">${'%s (%s)'%(__appname__, __version__)} [http://${__appname__}.kovidgoyal.net]</dc:contributor>
|
||||
@ -16,9 +16,9 @@
|
||||
<dc:description py:if="mi.comments">${mi.comments}</dc:description>
|
||||
<dc:publisher py:if="mi.publisher">${mi.publisher}</dc:publisher>
|
||||
<dc:identifier opf:scheme="ISBN" py:if="mi.isbn">${mi.isbn}</dc:identifier>
|
||||
<series py:if="mi.series">${mi.series}</series>
|
||||
<series_index py:if="mi.series_index is not None">${mi.series_index}</series_index>
|
||||
<rating py:if="mi.rating is not None">${mi.rating}</rating>
|
||||
<meta py:if="mi.series is not None" name="calibre:series" content="${mi.series}"/>
|
||||
<meta py:if="mi.series_index is not None" name="calibre:series_index" content="${mi.series_index}"/>
|
||||
<meta py:if="mi.rating is not None" name="calibre:rating" content="${mi.rating}"/>
|
||||
<py:for each="tag in mi.tags">
|
||||
<dc:subject py:if="mi.tags is not None">${tag}</dc:subject>
|
||||
</py:for>
|
||||
|
@ -392,8 +392,8 @@ class MetadataField(object):
|
||||
def __set__(self, obj, val):
|
||||
elem = obj.get_metadata_element(self.name)
|
||||
if elem is None:
|
||||
elem = obj.create_metadata_element(self.name, ns='dc' if self.is_dc else 'opf')
|
||||
elem.text = unicode(val)
|
||||
elem = obj.create_metadata_element(self.name, is_dc=self.is_dc)
|
||||
obj.set_text(elem, unicode(val))
|
||||
|
||||
class OPF(object):
|
||||
MIMETYPE = 'application/oebps-package+xml'
|
||||
@ -403,16 +403,17 @@ class OPF(object):
|
||||
'dc' : "http://purl.org/dc/elements/1.1/",
|
||||
'opf' : "http://www.idpf.org/2007/opf",
|
||||
}
|
||||
META = '{%s}meta' % NAMESPACES['opf']
|
||||
xpn = NAMESPACES.copy()
|
||||
xpn.pop(None)
|
||||
xpn['re'] = 'http://exslt.org/regular-expressions'
|
||||
XPath = functools.partial(etree.XPath, namespaces=xpn)
|
||||
CONTENT = XPath('self::*[re:match(name(), "meta$", "i")]/@content')
|
||||
TEXT = XPath('string()')
|
||||
|
||||
|
||||
metadata_path = XPath('descendant::*[re:match(name(), "metadata", "i")]')
|
||||
metadata_elem_path = XPath('descendant::*[re:match(name(), $name, "i")]')
|
||||
series_path = XPath('descendant::*[re:match(name(), "series$", "i")]')
|
||||
metadata_elem_path = XPath('descendant::*[re:match(name(), concat($name, "$"), "i") or (re:match(name(), "meta$", "i") and re:match(@name, concat("^calibre:", $name, "$"), "i"))]')
|
||||
authors_path = XPath('descendant::*[re:match(name(), "creator", "i") and (@role="aut" or @opf:role="aut" or (not(@role) and not(@opf:role)))]')
|
||||
bkp_path = XPath('descendant::*[re:match(name(), "contributor", "i") and (@role="bkp" or @opf:role="bkp")]')
|
||||
tags_path = XPath('descendant::*[re:match(name(), "subject", "i")]')
|
||||
@ -431,6 +432,7 @@ class OPF(object):
|
||||
language = MetadataField('language')
|
||||
comments = MetadataField('description')
|
||||
category = MetadataField('category')
|
||||
series = MetadataField('series', is_dc=False)
|
||||
series_index = MetadataField('series_index', is_dc=False, formatter=int, none_is=1)
|
||||
rating = MetadataField('rating', is_dc=False, formatter=int)
|
||||
|
||||
@ -497,7 +499,13 @@ class OPF(object):
|
||||
|
||||
|
||||
def get_text(self, elem):
|
||||
return u''.join(self.TEXT(elem))
|
||||
return u''.join(self.CONTENT(elem) or self.TEXT(elem))
|
||||
|
||||
def set_text(self, elem, content):
|
||||
if elem.tag == self.META:
|
||||
elem.attib['content'] = content
|
||||
else:
|
||||
elem.text = content
|
||||
|
||||
def itermanifest(self):
|
||||
return self.manifest_path(self.root)
|
||||
@ -611,9 +619,9 @@ class OPF(object):
|
||||
for elem in remove:
|
||||
self.metadata.remove(elem)
|
||||
for author in val:
|
||||
elem = self.create_metadata_element('creator', ns='dc',
|
||||
attrib={'{%s}role'%self.NAMESPACES['opf']:'aut'})
|
||||
elem.text = author
|
||||
attrib = {'{%s}role'%self.NAMESPACES['opf']: 'aut'}
|
||||
elem = self.create_metadata_element('creator', attrib=attrib)
|
||||
self.set_text(elem, author)
|
||||
|
||||
return property(fget=fget, fset=fset)
|
||||
|
||||
@ -650,8 +658,8 @@ class OPF(object):
|
||||
for tag in list(self.tags_path(self.metadata)):
|
||||
self.metadata.remove(tag)
|
||||
for tag in val:
|
||||
elem = self.create_metadata_element('subject', ns='dc')
|
||||
elem.text = unicode(tag)
|
||||
elem = self.create_metadata_element('subject')
|
||||
self.set_text(elem, unicode(tag))
|
||||
|
||||
return property(fget=fget, fset=fset)
|
||||
|
||||
@ -660,14 +668,15 @@ class OPF(object):
|
||||
|
||||
def fget(self):
|
||||
for match in self.isbn_path(self.metadata):
|
||||
return match.text if match.text else None
|
||||
return self.get_text(match) or None
|
||||
|
||||
def fset(self, val):
|
||||
matches = self.isbn_path(self.metadata)
|
||||
if not matches:
|
||||
matches = [self.create_metadata_element('identifier', ns='dc',
|
||||
attrib={'{%s}scheme'%self.NAMESPACES['opf']:'ISBN'})]
|
||||
matches[0].text = unicode(val)
|
||||
attrib = {'{%s}scheme'%self.NAMESPACES['opf']: 'ISBN'}
|
||||
matches = [self.create_metadata_element('identifier',
|
||||
attrib=attrib)]
|
||||
self.set_text(matches[0], unicode(val))
|
||||
|
||||
return property(fget=fget, fset=fset)
|
||||
|
||||
@ -676,48 +685,32 @@ class OPF(object):
|
||||
|
||||
def fget(self):
|
||||
for match in self.application_id_path(self.metadata):
|
||||
return match.text if match.text else None
|
||||
return self.get_text(match) or None
|
||||
|
||||
def fset(self, val):
|
||||
matches = self.application_id_path(self.metadata)
|
||||
if not matches:
|
||||
matches = [self.create_metadata_element('identifier', ns='dc',
|
||||
attrib={'{%s}scheme'%self.NAMESPACES['opf']:'calibre'})]
|
||||
matches[0].text = unicode(val)
|
||||
attrib = {'{%s}scheme'%self.NAMESPACES['opf']: 'calibre'}
|
||||
matches = [self.create_metadata_element('identifier',
|
||||
attrib=attrib)]
|
||||
self.set_text(matches[0], unicode(val))
|
||||
|
||||
return property(fget=fget, fset=fset)
|
||||
|
||||
|
||||
@apply
|
||||
def series():
|
||||
|
||||
def fget(self):
|
||||
for match in self.series_path(self.metadata):
|
||||
return match.text if match.text else None
|
||||
|
||||
def fset(self, val):
|
||||
matches = self.series_path(self.metadata)
|
||||
if not matches:
|
||||
matches = [self.create_metadata_element('series')]
|
||||
matches[0].text = unicode(val)
|
||||
|
||||
return property(fget=fget, fset=fset)
|
||||
|
||||
|
||||
|
||||
@apply
|
||||
def book_producer():
|
||||
|
||||
def fget(self):
|
||||
for match in self.bkp_path(self.metadata):
|
||||
return match.text if match.text else None
|
||||
return self.get_text(match) or None
|
||||
|
||||
def fset(self, val):
|
||||
matches = self.bkp_path(self.metadata)
|
||||
if not matches:
|
||||
matches = [self.create_metadata_element('contributor', ns='dc',
|
||||
attrib={'{%s}role'%self.NAMESPACES['opf']:'bkp'})]
|
||||
matches[0].text = unicode(val)
|
||||
attrib = {'{%s}role'%self.NAMESPACES['opf']: 'bkp'}
|
||||
matches = [self.create_metadata_element('contributor',
|
||||
attrib=attrib)]
|
||||
self.set_text(matches[0], unicode(val))
|
||||
return property(fget=fget, fset=fset)
|
||||
|
||||
|
||||
@ -783,9 +776,15 @@ class OPF(object):
|
||||
if matches:
|
||||
return matches[-1]
|
||||
|
||||
def create_metadata_element(self, name, attrib=None, ns='opf'):
|
||||
elem = etree.SubElement(self.metadata, '{%s}%s'%(self.NAMESPACES[ns], name),
|
||||
attrib=attrib, nsmap=self.NAMESPACES)
|
||||
def create_metadata_element(self, name, attrib=None, is_dc=True):
|
||||
if is_dc:
|
||||
name = '{%s}%s' % (self.NAMESPACES['dc'], name)
|
||||
else:
|
||||
attrib = attrib or {}
|
||||
attrib['name'] = 'calibre:' + name
|
||||
name = '{%s}%s' % (self.NAMESPACES['opf'], 'meta')
|
||||
elem = etree.SubElement(self.metadata, name, attrib=attrib,
|
||||
nsmap=self.NAMESPACES)
|
||||
elem.tail = '\n'
|
||||
return elem
|
||||
|
||||
|
@ -148,10 +148,6 @@ class MobiMLizer(object):
|
||||
if bstate.pbreak:
|
||||
etree.SubElement(body, MBP('pagebreak'))
|
||||
bstate.pbreak = False
|
||||
if istate.ids:
|
||||
for id in istate.ids:
|
||||
etree.SubElement(body, XHTML('a'), attrib={'id': id})
|
||||
istate.ids.clear()
|
||||
bstate.istate = None
|
||||
bstate.anchor = None
|
||||
parent = bstate.nested[-1] if bstate.nested else bstate.body
|
||||
@ -186,14 +182,17 @@ class MobiMLizer(object):
|
||||
wrapper.attrib['height'] = self.mobimlize_measure(vspace)
|
||||
para.attrib['width'] = self.mobimlize_measure(indent)
|
||||
elif tag == 'table' and vspace > 0:
|
||||
body = bstate.body
|
||||
vspace = int(round(vspace / self.profile.fbase))
|
||||
index = max((0, len(body) - 1))
|
||||
while vspace > 0:
|
||||
body.insert(index, etree.Element(XHTML('br')))
|
||||
wrapper.addprevious(etree.Element(XHTML('br')))
|
||||
vspace -= 1
|
||||
if istate.halign != 'auto':
|
||||
para.attrib['align'] = istate.halign
|
||||
if istate.ids:
|
||||
last = bstate.body[-1]
|
||||
for id in istate.ids:
|
||||
last.addprevious(etree.Element(XHTML('a'), attrib={'id': id}))
|
||||
istate.ids.clear()
|
||||
pstate = bstate.istate
|
||||
if tag in CONTENT_TAGS:
|
||||
bstate.inline = para
|
||||
|
@ -5,7 +5,7 @@ __copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||
Read data from .mobi files
|
||||
'''
|
||||
|
||||
import sys, struct, os, cStringIO, re, atexit, shutil, tempfile
|
||||
import sys, struct, os, cStringIO, re
|
||||
|
||||
try:
|
||||
from PIL import Image as PILImage
|
||||
@ -14,7 +14,7 @@ except ImportError:
|
||||
|
||||
from lxml import html, etree
|
||||
|
||||
from calibre import __appname__, entity_to_unicode
|
||||
from calibre import entity_to_unicode
|
||||
from calibre.ebooks import DRMError
|
||||
from calibre.ebooks.chardet import ENCODING_PATS
|
||||
from calibre.ebooks.mobi import MobiError
|
||||
@ -28,7 +28,7 @@ from calibre import sanitize_file_name
|
||||
|
||||
class EXTHHeader(object):
|
||||
|
||||
def __init__(self, raw, codec):
|
||||
def __init__(self, raw, codec, title):
|
||||
self.doctype = raw[:4]
|
||||
self.length, self.num_items = struct.unpack('>LL', raw[4:12])
|
||||
raw = raw[12:]
|
||||
@ -45,22 +45,16 @@ class EXTHHeader(object):
|
||||
elif id == 203:
|
||||
self.has_fake_cover = bool(struct.unpack('>L', content)[0])
|
||||
elif id == 201:
|
||||
self.cover_offset, = struct.unpack('>L', content)
|
||||
co, = struct.unpack('>L', content)
|
||||
if co < 1e7:
|
||||
self.cover_offset = co
|
||||
elif id == 202:
|
||||
self.thumbnail_offset, = struct.unpack('>L', content)
|
||||
#else:
|
||||
# print 'unknown record', id, repr(content)
|
||||
title = re.search(r'\0+([^\0]+)\0+', raw[pos:])
|
||||
if title:
|
||||
title = title.group(1).decode(codec, 'replace')
|
||||
if len(title) > 2:
|
||||
self.mi.title = title
|
||||
else:
|
||||
title = re.search(r'\0+([^\0]+)\0+', ''.join(reversed(raw[pos:])))
|
||||
if title:
|
||||
self.mi.title = ''.join(reversed(title.group(1).decode(codec, 'replace')))
|
||||
|
||||
|
||||
self.mi.title = title
|
||||
|
||||
def process_metadata(self, id, content, codec):
|
||||
if id == 100:
|
||||
if self.mi.authors == [_('Unknown')]:
|
||||
@ -119,6 +113,9 @@ class BookHeader(object):
|
||||
if self.compression_type == 'DH':
|
||||
self.huff_offset, self.huff_number = struct.unpack('>LL', raw[0x70:0x78])
|
||||
|
||||
toff, tlen = struct.unpack('>II', raw[0x54:0x5c])
|
||||
tend = toff + tlen
|
||||
self.title = raw[toff:tend] if tend < len(raw) else _('Unknown')
|
||||
langcode = struct.unpack('!L', raw[0x5C:0x60])[0]
|
||||
langid = langcode & 0xFF
|
||||
sublangid = (langcode >> 10) & 0xFF
|
||||
@ -129,7 +126,7 @@ class BookHeader(object):
|
||||
self.exth_flag, = struct.unpack('>L', raw[0x80:0x84])
|
||||
self.exth = None
|
||||
if self.exth_flag & 0x40:
|
||||
self.exth = EXTHHeader(raw[16+self.length:], self.codec)
|
||||
self.exth = EXTHHeader(raw[16+self.length:], self.codec, self.title)
|
||||
self.exth.mi.uid = self.unique_id
|
||||
self.exth.mi.language = self.language
|
||||
|
||||
@ -480,7 +477,7 @@ def get_metadata(stream):
|
||||
try:
|
||||
if hasattr(mr.book_header.exth, 'cover_offset'):
|
||||
cover_index = mr.book_header.first_image_index + mr.book_header.exth.cover_offset
|
||||
data = mr.sections[cover_index][0]
|
||||
data = mr.sections[int(cover_index)][0]
|
||||
else:
|
||||
data = mr.sections[mr.book_header.first_image_index][0]
|
||||
buf = cStringIO.StringIO(data)
|
||||
|
@ -23,6 +23,7 @@ from PIL import Image
|
||||
from calibre.ebooks.oeb.base import XML_NS, XHTML, XHTML_NS, OEB_DOCS, \
|
||||
OEB_RASTER_IMAGES
|
||||
from calibre.ebooks.oeb.base import xpath, barename, namespace, prefixname
|
||||
from calibre.ebooks.oeb.base import urlnormalize
|
||||
from calibre.ebooks.oeb.base import Logger, OEBBook
|
||||
from calibre.ebooks.oeb.profile import Context
|
||||
from calibre.ebooks.oeb.transforms.flatcss import CSSFlattener
|
||||
@ -178,7 +179,7 @@ class Serializer(object):
|
||||
|
||||
def serialize_href(self, href, base=None):
|
||||
hrefs = self.oeb.manifest.hrefs
|
||||
path, frag = urldefrag(href)
|
||||
path, frag = urldefrag(urlnormalize(href))
|
||||
if path and base:
|
||||
path = base.abshref(path)
|
||||
if path and path not in hrefs:
|
||||
@ -196,6 +197,7 @@ class Serializer(object):
|
||||
|
||||
def serialize_body(self):
|
||||
buffer = self.buffer
|
||||
self.anchor_offset = buffer.tell()
|
||||
buffer.write('<body>')
|
||||
# CybookG3 'Start Reading' link
|
||||
if 'text' in self.oeb.guide:
|
||||
@ -224,14 +226,17 @@ class Serializer(object):
|
||||
or namespace(elem.tag) not in nsrmap:
|
||||
return
|
||||
tag = prefixname(elem.tag, nsrmap)
|
||||
for attr in ('name', 'id'):
|
||||
if attr in elem.attrib:
|
||||
href = '#'.join((item.href, elem.attrib[attr]))
|
||||
self.id_offsets[href] = buffer.tell()
|
||||
del elem.attrib[attr]
|
||||
if tag == 'a' and not elem.attrib \
|
||||
and not len(elem) and not elem.text:
|
||||
# Previous layers take care of @name
|
||||
id = elem.attrib.pop('id', None)
|
||||
if id is not None:
|
||||
href = '#'.join((item.href, id))
|
||||
offset = self.anchor_offset or buffer.tell()
|
||||
self.id_offsets[href] = offset
|
||||
if self.anchor_offset is not None and \
|
||||
tag == 'a' and not elem.attrib and \
|
||||
not len(elem) and not elem.text:
|
||||
return
|
||||
self.anchor_offset = buffer.tell()
|
||||
buffer.write('<')
|
||||
buffer.write(tag)
|
||||
if elem.attrib:
|
||||
@ -256,10 +261,12 @@ class Serializer(object):
|
||||
if elem.text or len(elem) > 0:
|
||||
buffer.write('>')
|
||||
if elem.text:
|
||||
self.anchor_offset = None
|
||||
self.serialize_text(elem.text)
|
||||
for child in elem:
|
||||
self.serialize_elem(child, item)
|
||||
if child.tail:
|
||||
self.anchor_offset = None
|
||||
self.serialize_text(child.tail)
|
||||
buffer.write('</%s>' % tag)
|
||||
else:
|
||||
|
@ -23,12 +23,15 @@ from calibre import LoggingInterface
|
||||
from calibre.translations.dynamic import translate
|
||||
from calibre.startup import get_lang
|
||||
from calibre.ebooks.oeb.entitydefs import ENTITYDEFS
|
||||
from calibre.ebooks.metadata.epub import CoverRenderer
|
||||
from calibre.ptempfile import TemporaryDirectory
|
||||
|
||||
XML_NS = 'http://www.w3.org/XML/1998/namespace'
|
||||
XHTML_NS = 'http://www.w3.org/1999/xhtml'
|
||||
OEB_DOC_NS = 'http://openebook.org/namespaces/oeb-document/1.0/'
|
||||
OPF1_NS = 'http://openebook.org/namespaces/oeb-package/1.0/'
|
||||
OPF2_NS = 'http://www.idpf.org/2007/opf'
|
||||
OPF_NSES = set([OPF1_NS, OPF2_NS])
|
||||
DC09_NS = 'http://purl.org/metadata/dublin_core'
|
||||
DC10_NS = 'http://purl.org/dc/elements/1.0/'
|
||||
DC11_NS = 'http://purl.org/dc/elements/1.1/'
|
||||
@ -38,6 +41,7 @@ DCTERMS_NS = 'http://purl.org/dc/terms/'
|
||||
NCX_NS = 'http://www.daisy.org/z3986/2005/ncx/'
|
||||
SVG_NS = 'http://www.w3.org/2000/svg'
|
||||
XLINK_NS = 'http://www.w3.org/1999/xlink'
|
||||
CALIBRE_NS = 'http://calibre.kovidgoyal.net/2009/metadata'
|
||||
XPNSMAP = {'h': XHTML_NS, 'o1': OPF1_NS, 'o2': OPF2_NS,
|
||||
'd09': DC09_NS, 'd10': DC10_NS, 'd11': DC11_NS,
|
||||
'xsi': XSI_NS, 'dt': DCTERMS_NS, 'ncx': NCX_NS,
|
||||
@ -51,6 +55,7 @@ def DC(name): return '{%s}%s' % (DC11_NS, name)
|
||||
def NCX(name): return '{%s}%s' % (NCX_NS, name)
|
||||
def SVG(name): return '{%s}%s' % (SVG_NS, name)
|
||||
def XLINK(name): return '{%s}%s' % (XLINK_NS, name)
|
||||
def CALIBRE(name): return '{%s}%s' % (CALIBRE_NS, name)
|
||||
|
||||
EPUB_MIME = 'application/epub+zip'
|
||||
XHTML_MIME = 'application/xhtml+xml'
|
||||
@ -75,6 +80,8 @@ MS_COVER_TYPE = 'other.ms-coverimage-standard'
|
||||
|
||||
ENTITY_RE = re.compile(r'&([a-zA-Z_:][a-zA-Z0-9.-_:]+);')
|
||||
COLLAPSE_RE = re.compile(r'[ \t\r\n\v]+')
|
||||
QNAME_RE = re.compile(r'^[{][^{}]+[}][^{}]+$')
|
||||
PREFIXNAME_RE = re.compile(r'^[^:]+[:][^:]+')
|
||||
|
||||
def element(parent, *args, **kwargs):
|
||||
if parent is not None:
|
||||
@ -92,11 +99,30 @@ def barename(name):
|
||||
return name
|
||||
|
||||
def prefixname(name, nsrmap):
|
||||
prefix = nsrmap[namespace(name)]
|
||||
if not isqname(name):
|
||||
return name
|
||||
ns = namespace(name)
|
||||
if ns not in nsrmap:
|
||||
return name
|
||||
prefix = nsrmap[ns]
|
||||
if not prefix:
|
||||
return barename(name)
|
||||
return ':'.join((prefix, barename(name)))
|
||||
|
||||
def isprefixname(name):
|
||||
return name and PREFIXNAME_RE.match(name) is not None
|
||||
|
||||
def qname(name, nsmap):
|
||||
if not isprefixname(name):
|
||||
return name
|
||||
prefix, local = name.split(':', 1)
|
||||
if prefix not in nsmap:
|
||||
return name
|
||||
return '{%s}%s' % (nsmap[prefix], local)
|
||||
|
||||
def isqname(name):
|
||||
return name and QNAME_RE.match(name) is not None
|
||||
|
||||
def XPath(expr):
|
||||
return etree.XPath(expr, namespaces=XPNSMAP)
|
||||
|
||||
@ -185,48 +211,65 @@ class DirWriter(object):
|
||||
|
||||
|
||||
class Metadata(object):
|
||||
TERMS = set(['contributor', 'coverage', 'creator', 'date', 'description',
|
||||
'format', 'identifier', 'language', 'publisher', 'relation',
|
||||
'rights', 'source', 'subject', 'title', 'type'])
|
||||
ATTRS = set(['role', 'file-as', 'scheme'])
|
||||
DC_TERMS = set(['contributor', 'coverage', 'creator', 'date', 'description',
|
||||
'format', 'identifier', 'language', 'publisher', 'relation',
|
||||
'rights', 'source', 'subject', 'title', 'type'])
|
||||
CALIBRE_TERMS = set(['series', 'series_index', 'rating'])
|
||||
OPF_ATTRS = set(['role', 'file-as', 'scheme', 'event'])
|
||||
OPF1_NSMAP = {'dc': DC11_NS, 'oebpackage': OPF1_NS}
|
||||
OPF2_NSMAP = {'opf': OPF2_NS, 'dc': DC11_NS, 'dcterms': DCTERMS_NS,
|
||||
'xsi': XSI_NS}
|
||||
'xsi': XSI_NS, 'calibre': CALIBRE_NS}
|
||||
|
||||
class Item(object):
|
||||
def __init__(self, term, value, fq_attrib={}, **kwargs):
|
||||
self.fq_attrib = fq_attrib = dict(fq_attrib)
|
||||
fq_attrib.update(kwargs)
|
||||
if barename(term).lower() in Metadata.TERMS and \
|
||||
(not namespace(term) or namespace(term) in DC_NSES):
|
||||
# Anything looking like Dublin Core is coerced
|
||||
term = DC(barename(term).lower())
|
||||
elif namespace(term) == OPF2_NS:
|
||||
def __init__(self, term, value, attrib={}, nsmap={}, **kwargs):
|
||||
self.attrib = attrib = dict(attrib)
|
||||
self.nsmap = nsmap = dict(nsmap)
|
||||
attrib.update(kwargs)
|
||||
if namespace(term) == OPF2_NS:
|
||||
term = barename(term)
|
||||
ns = namespace(term)
|
||||
local = barename(term).lower()
|
||||
if local in Metadata.DC_TERMS and (not ns or ns in DC_NSES):
|
||||
# Anything looking like Dublin Core is coerced
|
||||
term = DC(local)
|
||||
elif local in Metadata.CALIBRE_TERMS and ns in (CALIBRE_NS, ''):
|
||||
# Ditto for Calibre-specific metadata
|
||||
term = CALIBRE(local)
|
||||
self.term = term
|
||||
self.value = value
|
||||
self.attrib = attrib = {}
|
||||
for fq_attr in fq_attrib:
|
||||
if fq_attr in Metadata.ATTRS:
|
||||
attr = fq_attr
|
||||
fq_attr = OPF(fq_attr)
|
||||
fq_attrib[fq_attr] = fq_attrib.pop(attr)
|
||||
else:
|
||||
attr = barename(fq_attr)
|
||||
attrib[attr] = fq_attrib[fq_attr]
|
||||
for attr, value in attrib.items():
|
||||
if isprefixname(value):
|
||||
attrib[attr] = qname(value, nsmap)
|
||||
if attr in Metadata.OPF_ATTRS:
|
||||
attrib[OPF(attr)] = attrib.pop(attr)
|
||||
self.__setattr__ = self._setattr
|
||||
|
||||
def __getattr__(self, name):
|
||||
name = name.replace('_', '-')
|
||||
attr = name.replace('_', '-')
|
||||
if attr in Metadata.OPF_ATTRS:
|
||||
attr = OPF(attr)
|
||||
try:
|
||||
return self.attrib[name]
|
||||
return self.attrib[attr]
|
||||
except KeyError:
|
||||
raise AttributeError(
|
||||
'%r object has no attribute %r' \
|
||||
% (self.__class__.__name__, name))
|
||||
|
||||
|
||||
def _setattr(self, name, value):
|
||||
attr = name.replace('_', '-')
|
||||
if attr in Metadata.OPF_ATTRS:
|
||||
attr = OPF(attr)
|
||||
if attr in self.attrib:
|
||||
self.attrib[attr] = value
|
||||
return
|
||||
super(Item, self).__setattr__(self, name, value)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.attrib[key]
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self.attrib[key] = value
|
||||
|
||||
def __contains__(self, key):
|
||||
return key in self.attrib
|
||||
|
||||
@ -243,33 +286,41 @@ class Metadata(object):
|
||||
def __unicode__(self):
|
||||
return unicode(self.value)
|
||||
|
||||
def to_opf1(self, dcmeta=None, xmeta=None):
|
||||
def to_opf1(self, dcmeta=None, xmeta=None, nsrmap={}):
|
||||
attrib = {}
|
||||
for key, value in self.attrib.items():
|
||||
if namespace(key) == OPF2_NS:
|
||||
key = barename(key)
|
||||
attrib[key] = prefixname(value, nsrmap)
|
||||
if namespace(self.term) == DC11_NS:
|
||||
name = DC(barename(self.term).title())
|
||||
elem = element(dcmeta, name, attrib=self.attrib)
|
||||
elem = element(dcmeta, name, attrib=attrib)
|
||||
elem.text = self.value
|
||||
else:
|
||||
elem = element(xmeta, 'meta', attrib=self.attrib)
|
||||
elem.attrib['name'] = self.term
|
||||
elem.attrib['content'] = self.value
|
||||
elem = element(xmeta, 'meta', attrib=attrib)
|
||||
elem.attrib['name'] = prefixname(self.term, nsrmap)
|
||||
elem.attrib['content'] = prefixname(self.value, nsrmap)
|
||||
return elem
|
||||
|
||||
def to_opf2(self, parent=None):
|
||||
def to_opf2(self, parent=None, nsrmap={}):
|
||||
attrib = {}
|
||||
for key, value in self.attrib.items():
|
||||
attrib[key] = prefixname(value, nsrmap)
|
||||
if namespace(self.term) == DC11_NS:
|
||||
elem = element(parent, self.term, attrib=self.fq_attrib)
|
||||
elem = element(parent, self.term, attrib=attrib)
|
||||
elem.text = self.value
|
||||
else:
|
||||
elem = element(parent, OPF('meta'), attrib=self.fq_attrib)
|
||||
elem.attrib['name'] = self.term
|
||||
elem.attrib['content'] = self.value
|
||||
elem = element(parent, OPF('meta'), attrib=attrib)
|
||||
elem.attrib['name'] = prefixname(self.term, nsrmap)
|
||||
elem.attrib['content'] = prefixname(self.value, nsrmap)
|
||||
return elem
|
||||
|
||||
def __init__(self, oeb):
|
||||
self.oeb = oeb
|
||||
self.items = defaultdict(list)
|
||||
|
||||
def add(self, term, value, attrib={}, **kwargs):
|
||||
item = self.Item(term, value, attrib, **kwargs)
|
||||
def add(self, term, value, attrib={}, nsmap={}, **kwargs):
|
||||
item = self.Item(term, value, attrib, nsmap, **kwargs)
|
||||
items = self.items[barename(item.term)]
|
||||
items.append(item)
|
||||
return item
|
||||
@ -288,23 +339,55 @@ class Metadata(object):
|
||||
def __getattr__(self, term):
|
||||
return self.items[term]
|
||||
|
||||
def _nsmap():
|
||||
def fget(self):
|
||||
nsmap = {}
|
||||
for term in self.items:
|
||||
for item in self.items[term]:
|
||||
nsmap.update(item.nsmap)
|
||||
return nsmap
|
||||
return property(fget=fget)
|
||||
_nsmap = _nsmap()
|
||||
|
||||
def _opf1_nsmap():
|
||||
def fget(self):
|
||||
nsmap = self._nsmap
|
||||
for key, value in nsmap.items():
|
||||
if value in OPF_NSES or value in DC_NSES:
|
||||
del nsmap[key]
|
||||
return nsmap
|
||||
return property(fget=fget)
|
||||
_opf1_nsmap = _opf1_nsmap()
|
||||
|
||||
def _opf2_nsmap():
|
||||
def fget(self):
|
||||
nsmap = self._nsmap
|
||||
nsmap.update(self.OPF2_NSMAP)
|
||||
return nsmap
|
||||
return property(fget=fget)
|
||||
_opf2_nsmap = _opf2_nsmap()
|
||||
|
||||
def to_opf1(self, parent=None):
|
||||
elem = element(parent, 'metadata')
|
||||
nsmap = self._opf1_nsmap
|
||||
nsrmap = dict((value, key) for key, value in nsmap.items())
|
||||
elem = element(parent, 'metadata', nsmap=nsmap)
|
||||
dcmeta = element(elem, 'dc-metadata', nsmap=self.OPF1_NSMAP)
|
||||
xmeta = element(elem, 'x-metadata')
|
||||
for term in self.items:
|
||||
for item in self.items[term]:
|
||||
item.to_opf1(dcmeta, xmeta)
|
||||
item.to_opf1(dcmeta, xmeta, nsrmap=nsrmap)
|
||||
if 'ms-chaptertour' not in self.items:
|
||||
chaptertour = self.Item('ms-chaptertour', 'chaptertour')
|
||||
chaptertour.to_opf1(dcmeta, xmeta)
|
||||
chaptertour.to_opf1(dcmeta, xmeta, nsrmap=nsrmap)
|
||||
return elem
|
||||
|
||||
def to_opf2(self, parent=None):
|
||||
elem = element(parent, OPF('metadata'), nsmap=self.OPF2_NSMAP)
|
||||
nsmap = self._opf2_nsmap
|
||||
nsrmap = dict((value, key) for key, value in nsmap.items())
|
||||
elem = element(parent, OPF('metadata'), nsmap=nsmap)
|
||||
for term in self.items:
|
||||
for item in self.items[term]:
|
||||
item.to_opf2(elem)
|
||||
item.to_opf2(elem, nsrmap=nsrmap)
|
||||
return elem
|
||||
|
||||
|
||||
@ -351,9 +434,13 @@ class Manifest(object):
|
||||
try:
|
||||
data = etree.fromstring(data)
|
||||
except etree.XMLSyntaxError:
|
||||
# TODO: Factor out HTML->XML coercion
|
||||
self.oeb.logger.warn('Parsing file %r as HTML' % self.href)
|
||||
data = html.fromstring(data)
|
||||
data.attrib.pop('xmlns', None)
|
||||
for elem in data.iter(tag=etree.Comment):
|
||||
if elem.text:
|
||||
elem.text = elem.text.strip('-')
|
||||
data = etree.tostring(data, encoding=unicode)
|
||||
data = etree.fromstring(data)
|
||||
# Force into the XHTML namespace
|
||||
@ -447,7 +534,7 @@ class Manifest(object):
|
||||
return cmp(skey, okey)
|
||||
|
||||
def relhref(self, href):
|
||||
if '/' not in self.href:
|
||||
if '/' not in self.href or ':' in href:
|
||||
return href
|
||||
base = os.path.dirname(self.href).split('/')
|
||||
target, frag = urldefrag(href)
|
||||
@ -463,7 +550,7 @@ class Manifest(object):
|
||||
return relhref
|
||||
|
||||
def abshref(self, href):
|
||||
if '/' not in self.href:
|
||||
if '/' not in self.href or ':' in href:
|
||||
return href
|
||||
dirname = os.path.dirname(self.href)
|
||||
href = os.path.join(dirname, href)
|
||||
@ -546,7 +633,7 @@ class Manifest(object):
|
||||
elif media_type in OEB_STYLES:
|
||||
media_type = CSS_MIME
|
||||
attrib = {'id': item.id, 'href': item.href,
|
||||
'media-type': item.media_type}
|
||||
'media-type': media_type}
|
||||
if item.fallback:
|
||||
attrib['fallback'] = item.fallback
|
||||
element(elem, OPF('item'), attrib=attrib)
|
||||
@ -796,6 +883,9 @@ class TOC(object):
|
||||
|
||||
|
||||
class OEBBook(object):
|
||||
COVER_SVG_XP = XPath('h:body//svg:svg[position() = 1]')
|
||||
COVER_OBJECT_XP = XPath('h:body//h:object[@data][position() = 1]')
|
||||
|
||||
def __init__(self, opfpath=None, container=None, encoding=None,
|
||||
logger=FauxLogger()):
|
||||
if opfpath and not container:
|
||||
@ -809,27 +899,27 @@ class OEBBook(object):
|
||||
self._all_from_opf(opf)
|
||||
|
||||
def _clean_opf(self, opf):
|
||||
for elem in opf.iter():
|
||||
if isinstance(elem.tag, basestring) \
|
||||
and namespace(elem.tag) in ('', OPF1_NS):
|
||||
nsmap = {}
|
||||
for elem in opf.iter(tag=etree.Element):
|
||||
nsmap.update(elem.nsmap)
|
||||
for elem in opf.iter(tag=etree.Element):
|
||||
if namespace(elem.tag) in ('', OPF1_NS):
|
||||
elem.tag = OPF(barename(elem.tag))
|
||||
nsmap.update(Metadata.OPF2_NSMAP)
|
||||
attrib = dict(opf.attrib)
|
||||
nroot = etree.Element(OPF('package'),
|
||||
nsmap={None: OPF2_NS}, attrib=attrib)
|
||||
metadata = etree.SubElement(nroot, OPF('metadata'),
|
||||
nsmap={'opf': OPF2_NS, 'dc': DC11_NS,
|
||||
'xsi': XSI_NS, 'dcterms': DCTERMS_NS})
|
||||
dc = lambda prefix: xpath(opf, 'o2:metadata//%s:*' % prefix)
|
||||
for element in chain(*(dc(prefix) for prefix in DC_PREFIXES)):
|
||||
if not element.text: continue
|
||||
tag = barename(element.tag).lower()
|
||||
element.tag = '{%s}%s' % (DC11_NS, tag)
|
||||
for name in element.attrib:
|
||||
if name in ('role', 'file-as', 'scheme'):
|
||||
metadata = etree.SubElement(nroot, OPF('metadata'), nsmap=nsmap)
|
||||
ignored = (OPF('dc-metadata'), OPF('x-metadata'))
|
||||
for elem in xpath(opf, 'o2:metadata//*'):
|
||||
if namespace(elem.tag) in DC_NSES:
|
||||
tag = barename(elem.tag).lower()
|
||||
elem.tag = '{%s}%s' % (DC11_NS, tag)
|
||||
for name in elem.attrib:
|
||||
if name in ('role', 'file-as', 'scheme', 'event'):
|
||||
nsname = '{%s}%s' % (OPF2_NS, name)
|
||||
element.attrib[nsname] = element.attrib[name]
|
||||
del element.attrib[name]
|
||||
metadata.append(element)
|
||||
elem.attrib[nsname] = elem.attrib.pop(name)
|
||||
metadata.append(elem)
|
||||
for element in xpath(opf, 'o2:metadata//o2:meta'):
|
||||
metadata.append(element)
|
||||
for tag in ('o2:manifest', 'o2:spine', 'o2:tours', 'o2:guide'):
|
||||
@ -856,18 +946,18 @@ class OEBBook(object):
|
||||
uid = opf.get('unique-identifier', 'calibre-uuid')
|
||||
self.uid = None
|
||||
self.metadata = metadata = Metadata(self)
|
||||
ignored = (OPF('dc-metadata'), OPF('x-metadata'))
|
||||
for elem in xpath(opf, '/o2:package/o2:metadata//*'):
|
||||
if elem.tag in ignored: continue
|
||||
term = elem.tag
|
||||
value = elem.text
|
||||
attrib = dict(elem.attrib)
|
||||
nsmap = elem.nsmap
|
||||
if term == OPF('meta'):
|
||||
term = elem.attrib.pop('name', None)
|
||||
value = elem.attrib.pop('content', None)
|
||||
term = qname(attrib.pop('name', None), nsmap)
|
||||
value = attrib.pop('content', None)
|
||||
if value:
|
||||
value = COLLAPSE_RE.sub(' ', value.strip())
|
||||
if term and (value or elem.attrib):
|
||||
metadata.add(term, value, elem.attrib)
|
||||
if term and (value or attrib):
|
||||
metadata.add(term, value, attrib, nsmap=nsmap)
|
||||
haveuuid = haveid = False
|
||||
for ident in metadata.identifier:
|
||||
if unicode(ident).startswith('urn:uuid:'):
|
||||
@ -928,7 +1018,7 @@ class OEBBook(object):
|
||||
spine.add(item, elem.get('linear'))
|
||||
extras = []
|
||||
for item in self.manifest.values():
|
||||
if item.media_type == XHTML_MIME \
|
||||
if item.media_type in OEB_DOCS \
|
||||
and item not in spine:
|
||||
extras.append(item)
|
||||
extras.sort()
|
||||
@ -971,7 +1061,7 @@ class OEBBook(object):
|
||||
ncx = item.data
|
||||
self.manifest.remove(item)
|
||||
title = xpath(ncx, 'ncx:docTitle/ncx:text/text()')
|
||||
title = title[0].strip() if title else unicode(self.metadata.title)
|
||||
title = title[0].strip() if title else unicode(self.metadata.title[0])
|
||||
self.toc = toc = TOC(title)
|
||||
navmaps = xpath(ncx, 'ncx:navMap')
|
||||
for navmap in navmaps:
|
||||
@ -1051,42 +1141,59 @@ class OEBBook(object):
|
||||
if self._toc_from_html(opf): return
|
||||
self._toc_from_spine(opf)
|
||||
|
||||
def _ensure_cover_image(self):
|
||||
cover = None
|
||||
def _cover_from_html(self, hcover):
|
||||
with TemporaryDirectory('_html_cover') as tdir:
|
||||
writer = DirWriter()
|
||||
writer.dump(self, tdir)
|
||||
path = os.path.join(tdir, hcover.href)
|
||||
renderer = CoverRenderer(path)
|
||||
data = renderer.image_data
|
||||
id, href = self.manifest.generate('cover', 'cover.jpeg')
|
||||
item = self.manifest.add(id, href, JPEG_MIME, data=data)
|
||||
return item
|
||||
|
||||
def _locate_cover_image(self):
|
||||
if self.metadata.cover:
|
||||
id = str(self.metadata.cover[0])
|
||||
item = self.manifest.ids.get(id, None)
|
||||
if item is not None:
|
||||
return item
|
||||
hcover = self.spine[0]
|
||||
if 'cover' in self.guide:
|
||||
href = self.guide['cover'].href
|
||||
item = self.manifest.hrefs[href]
|
||||
media_type = item.media_type
|
||||
if media_type in OEB_RASTER_IMAGES:
|
||||
cover = item
|
||||
if media_type in OEB_IMAGES:
|
||||
return item
|
||||
elif media_type in OEB_DOCS:
|
||||
hcover = item
|
||||
html = hcover.data
|
||||
if cover is not None:
|
||||
pass
|
||||
elif self.metadata.cover:
|
||||
id = str(self.metadata.cover[0])
|
||||
cover = self.manifest.ids[id]
|
||||
elif MS_COVER_TYPE in self.guide:
|
||||
if MS_COVER_TYPE in self.guide:
|
||||
href = self.guide[MS_COVER_TYPE].href
|
||||
cover = self.manifest.hrefs[href]
|
||||
elif xpath(html, '//h:img[position()=1]'):
|
||||
img = xpath(html, '//h:img[position()=1]')[0]
|
||||
href = hcover.abshref(img.get('src'))
|
||||
cover = self.manifest.hrefs[href]
|
||||
elif xpath(html, '//h:object[position()=1]'):
|
||||
object = xpath(html, '//h:object[position()=1]')[0]
|
||||
href = hcover.abshref(object.get('data'))
|
||||
cover = self.manifest.hrefs[href]
|
||||
elif xpath(html, '//svg:svg[position()=1]'):
|
||||
svg = copy.deepcopy(xpath(html, '//svg:svg[position()=1]')[0])
|
||||
item = self.manifest.hrefs.get(href, None)
|
||||
if item is not None and item.media_type in OEB_IMAGES:
|
||||
return item
|
||||
if self.COVER_SVG_XP(html):
|
||||
svg = copy.deepcopy(self.COVER_SVG_XP(html)[0])
|
||||
href = os.path.splitext(hcover.href)[0] + '.svg'
|
||||
id, href = self.manifest.generate(hcover.id, href)
|
||||
cover = self.manifest.add(id, href, SVG_MIME, data=svg)
|
||||
if cover and not self.metadata.cover:
|
||||
self.metadata.add('cover', cover.id)
|
||||
|
||||
item = self.manifest.add(id, href, SVG_MIME, data=svg)
|
||||
return item
|
||||
if self.COVER_OBJECT_XP(html):
|
||||
object = self.COVER_OBJECT_XP(html)[0]
|
||||
href = hcover.abshref(object.get('data'))
|
||||
item = self.manifest.hrefs.get(href, None)
|
||||
if item is not None and item.media_type in OEB_IMAGES:
|
||||
return item
|
||||
return self._cover_from_html(hcover)
|
||||
|
||||
def _ensure_cover_image(self):
|
||||
cover = self._locate_cover_image()
|
||||
if self.metadata.cover:
|
||||
self.metadata.cover[0].value = cover.id
|
||||
return
|
||||
self.metadata.add('cover', cover.id)
|
||||
|
||||
def _all_from_opf(self, opf):
|
||||
self._metadata_from_opf(opf)
|
||||
self._manifest_from_opf(opf)
|
||||
|
@ -265,6 +265,8 @@ class Stylizer(object):
|
||||
|
||||
|
||||
class Style(object):
|
||||
UNIT_RE = re.compile(r'^(-*[0-9]*[.]?[0-9]*)\s*(%|em|px|mm|cm|in|pt|pc)$')
|
||||
|
||||
def __init__(self, element, stylizer):
|
||||
self._element = element
|
||||
self._profile = stylizer.profile
|
||||
@ -319,13 +321,11 @@ class Style(object):
|
||||
if isinstance(value, (int, long, float)):
|
||||
return value
|
||||
try:
|
||||
if float(value) == 0:
|
||||
return 0.0
|
||||
return float(value) * 72.0 / self._profile.dpi
|
||||
except:
|
||||
pass
|
||||
result = value
|
||||
m = re.search(
|
||||
r"^(-*[0-9]*\.?[0-9]*)\s*(%|em|px|mm|cm|in|pt|pc)$", value)
|
||||
m = self.UNIT_RE.match(value)
|
||||
if m is not None and m.group(1):
|
||||
value = float(m.group(1))
|
||||
unit = m.group(2)
|
||||
|
@ -23,6 +23,12 @@ from calibre.ebooks.oeb.stylizer import Stylizer
|
||||
COLLAPSE = re.compile(r'[ \t\r\n\v]+')
|
||||
STRIPNUM = re.compile(r'[-0-9]+$')
|
||||
|
||||
def asfloat(value, default):
|
||||
if not isinstance(value, (int, long, float)):
|
||||
value = default
|
||||
return float(value)
|
||||
|
||||
|
||||
class KeyMapper(object):
|
||||
def __init__(self, sbase, dbase, dkey):
|
||||
self.sbase = float(sbase)
|
||||
@ -179,12 +185,13 @@ class CSSFlattener(object):
|
||||
if cssdict:
|
||||
if self.lineh and self.fbase and tag != 'body':
|
||||
self.clean_edges(cssdict, style, psize)
|
||||
margin = style['margin-left']
|
||||
left += margin if isinstance(margin, float) else 0
|
||||
if (left + style['text-indent']) < 0:
|
||||
percent = (margin - style['text-indent']) / style['width']
|
||||
margin = asfloat(style['margin-left'], 0)
|
||||
indent = asfloat(style['text-indent'], 0)
|
||||
left += margin
|
||||
if (left + indent) < 0:
|
||||
percent = (margin - indent) / style['width']
|
||||
cssdict['margin-left'] = "%d%%" % (percent * 100)
|
||||
left -= style['text-indent']
|
||||
left -= indent
|
||||
if 'display' in cssdict and cssdict['display'] == 'in-line':
|
||||
cssdict['display'] = 'inline'
|
||||
if self.unfloat and 'float' in cssdict \
|
||||
|
@ -23,6 +23,7 @@ from PyQt4.QtGui import QApplication
|
||||
from calibre.ebooks.oeb.base import XHTML_NS, XHTML, SVG_NS, SVG, XLINK
|
||||
from calibre.ebooks.oeb.base import SVG_MIME, PNG_MIME, JPEG_MIME
|
||||
from calibre.ebooks.oeb.base import xml2str, xpath, namespace, barename
|
||||
from calibre.ebooks.oeb.base import urlnormalize
|
||||
from calibre.ebooks.oeb.stylizer import Stylizer
|
||||
|
||||
IMAGE_TAGS = set([XHTML('img'), XHTML('object')])
|
||||
@ -78,7 +79,7 @@ class SVGRasterizer(object):
|
||||
svg = item.data
|
||||
hrefs = self.oeb.manifest.hrefs
|
||||
for elem in xpath(svg, '//svg:*[@xl:href]'):
|
||||
href = elem.attrib[XLINK('href')]
|
||||
href = urlnormalize(elem.attrib[XLINK('href')])
|
||||
path, frag = urldefrag(href)
|
||||
if not path:
|
||||
continue
|
||||
@ -100,15 +101,15 @@ class SVGRasterizer(object):
|
||||
def rasterize_item(self, item, stylizer):
|
||||
html = item.data
|
||||
hrefs = self.oeb.manifest.hrefs
|
||||
for elem in xpath(html, '//h:img'):
|
||||
src = elem.get('src', None)
|
||||
image = hrefs.get(item.abshref(src), None) if src else None
|
||||
for elem in xpath(html, '//h:img[@src]'):
|
||||
src = urlnormalize(elem.attrib['src'])
|
||||
image = hrefs.get(item.abshref(src), None)
|
||||
if image and image.media_type == SVG_MIME:
|
||||
style = stylizer.style(elem)
|
||||
self.rasterize_external(elem, style, item, image)
|
||||
for elem in xpath(html, '//h:object[@type="%s"]' % SVG_MIME):
|
||||
data = elem.get('data', None)
|
||||
image = hrefs.get(item.abshref(data), None) if data else None
|
||||
for elem in xpath(html, '//h:object[@type="%s" and @data]' % SVG_MIME):
|
||||
data = urlnormalize(elem.attrib['data'])
|
||||
image = hrefs.get(item.abshref(data), None)
|
||||
if image and image.media_type == SVG_MIME:
|
||||
style = stylizer.style(elem)
|
||||
self.rasterize_external(elem, style, item, image)
|
||||
|
@ -54,7 +54,7 @@ class ManifestTrimmer(object):
|
||||
new.add(found)
|
||||
elif item.media_type == CSS_MIME:
|
||||
def replacer(uri):
|
||||
absuri = item.abshref(uri)
|
||||
absuri = item.abshref(urlnormalize(uri))
|
||||
if absuri in oeb.manifest.hrefs:
|
||||
found = oeb.manifest.hrefs[href]
|
||||
if found not in used:
|
||||
|
@ -15,7 +15,8 @@
|
||||
# #
|
||||
# #
|
||||
#########################################################################
|
||||
import sys, os
|
||||
import sys, os, shutil
|
||||
|
||||
class Copy:
|
||||
"""Copy each changed file to a directory for debugging purposes"""
|
||||
__dir = ""
|
||||
@ -64,25 +65,7 @@ class Copy:
|
||||
of cp. Otherwise, use a safe python method.
|
||||
"""
|
||||
write_file = os.path.join(Copy.__dir,new_file)
|
||||
platform = sys.platform
|
||||
if platform[:5] == 'linux':
|
||||
command = 'cp %(file)s %(write_file)s' % vars()
|
||||
os.system(command)
|
||||
else:
|
||||
read_obj = open(file,'r')
|
||||
write_obj = open(write_file, 'w')
|
||||
line = "dummy"
|
||||
while line:
|
||||
line = read_obj.read(1000)
|
||||
write_obj.write(line )
|
||||
read_obj.close()
|
||||
write_obj.close()
|
||||
shutil.copyfile(file, write_file)
|
||||
|
||||
def rename(self, source, dest):
|
||||
read_obj = open(source, 'r')
|
||||
write_obj = open(dest, 'w')
|
||||
line = 1
|
||||
while line:
|
||||
line = read_obj.readline()
|
||||
write_obj.write(line)
|
||||
read_obj.close()
|
||||
write_obj.close()
|
||||
shutil.copyfile(source, dest)
|
@ -252,7 +252,7 @@ class Config(ResizableDialog, Ui_Dialog):
|
||||
self.source_format = d.format()
|
||||
|
||||
def accept(self):
|
||||
for opt in ('chapter', 'level1_toc', 'level2_toc'):
|
||||
for opt in ('chapter', 'level1_toc', 'level2_toc', 'level3_toc'):
|
||||
text = unicode(getattr(self, 'opt_'+opt).text())
|
||||
if text:
|
||||
try:
|
||||
|
@ -93,7 +93,7 @@
|
||||
<item>
|
||||
<widget class="QStackedWidget" name="stack" >
|
||||
<property name="currentIndex" >
|
||||
<number>1</number>
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="metadata_page" >
|
||||
<layout class="QGridLayout" name="gridLayout_4" >
|
||||
@ -105,36 +105,6 @@
|
||||
<string>Book Cover</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="_2" >
|
||||
<item row="0" column="0" >
|
||||
<layout class="QHBoxLayout" name="_3" >
|
||||
<item>
|
||||
<widget class="ImageView" name="cover" >
|
||||
<property name="text" >
|
||||
<string/>
|
||||
</property>
|
||||
<property name="pixmap" >
|
||||
<pixmap resource="../images.qrc" >:/images/book.svg</pixmap>
|
||||
</property>
|
||||
<property name="scaledContents" >
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="alignment" >
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="2" column="0" >
|
||||
<widget class="QCheckBox" name="opt_prefer_metadata_cover" >
|
||||
<property name="text" >
|
||||
<string>Use cover from &source file</string>
|
||||
</property>
|
||||
<property name="checked" >
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0" >
|
||||
<layout class="QVBoxLayout" name="_4" >
|
||||
<property name="spacing" >
|
||||
@ -186,6 +156,36 @@
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="2" column="0" >
|
||||
<widget class="QCheckBox" name="opt_prefer_metadata_cover" >
|
||||
<property name="text" >
|
||||
<string>Use cover from &source file</string>
|
||||
</property>
|
||||
<property name="checked" >
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0" >
|
||||
<layout class="QHBoxLayout" name="_3" >
|
||||
<item>
|
||||
<widget class="ImageView" name="cover" >
|
||||
<property name="text" >
|
||||
<string/>
|
||||
</property>
|
||||
<property name="pixmap" >
|
||||
<pixmap resource="../images.qrc" >:/images/book.svg</pixmap>
|
||||
</property>
|
||||
<property name="scaledContents" >
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="alignment" >
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
<zorder>opt_prefer_metadata_cover</zorder>
|
||||
<zorder></zorder>
|
||||
@ -777,10 +777,10 @@ p, li { white-space: pre-wrap; }
|
||||
<item row="5" column="1" >
|
||||
<widget class="QLineEdit" name="opt_level2_toc" />
|
||||
</item>
|
||||
<item row="6" column="1" >
|
||||
<item row="7" column="1" >
|
||||
<widget class="QLineEdit" name="opt_toc_title" />
|
||||
</item>
|
||||
<item row="6" column="0" >
|
||||
<item row="7" column="0" >
|
||||
<widget class="QLabel" name="toc_title_label" >
|
||||
<property name="text" >
|
||||
<string>&Title for generated TOC</string>
|
||||
@ -790,6 +790,19 @@ p, li { white-space: pre-wrap; }
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="6" column="1" >
|
||||
<widget class="QLineEdit" name="opt_level3_toc" />
|
||||
</item>
|
||||
<item row="6" column="0" >
|
||||
<widget class="QLabel" name="label_11" >
|
||||
<property name="text" >
|
||||
<string>Level &3 TOC</string>
|
||||
</property>
|
||||
<property name="buddy" >
|
||||
<cstring>opt_level3_toc</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
|
@ -253,6 +253,7 @@ class LRFSingleDialog(QDialog, Ui_LRFSingleDialog):
|
||||
state = Qt.Checked if default else Qt.Unchecked
|
||||
obj.setCheckState(state)
|
||||
self.gui_headerformat.setDisabled(True)
|
||||
self.gui_header_separation.setDisabled(True)
|
||||
self.gui_use_metadata_cover.setCheckState(Qt.Checked)
|
||||
self.preprocess.addItem('No preprocessing')
|
||||
for opt in self.PREPROCESS_OPTIONS:
|
||||
|
@ -1055,5 +1055,37 @@ p, li { white-space: pre-wrap; }
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>gui_header</sender>
|
||||
<signal>toggled(bool)</signal>
|
||||
<receiver>gui_header_separation</receiver>
|
||||
<slot>setEnabled(bool)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel" >
|
||||
<x>235</x>
|
||||
<y>298</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel" >
|
||||
<x>361</x>
|
||||
<y>321</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>gui_header</sender>
|
||||
<signal>toggled(bool)</signal>
|
||||
<receiver>gui_headerformat</receiver>
|
||||
<slot>setEnabled(bool)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel" >
|
||||
<x>307</x>
|
||||
<y>300</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel" >
|
||||
<x>363</x>
|
||||
<y>362</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
|
@ -8,7 +8,7 @@ Scheduler for automated recipe downloads
|
||||
'''
|
||||
|
||||
import sys, copy, time
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, date
|
||||
from PyQt4.Qt import QDialog, QApplication, QLineEdit, QPalette, SIGNAL, QBrush, \
|
||||
QColor, QAbstractListModel, Qt, QVariant, QFont, QIcon, \
|
||||
QFile, QObject, QTimer, QMutex, QMenu, QAction, QTime
|
||||
@ -289,7 +289,8 @@ class SchedulerDialog(QDialog, Ui_Dialog):
|
||||
recipe.last_downloaded = datetime.fromordinal(1)
|
||||
recipes.append(recipe)
|
||||
if recipe.needs_subscription and not config['recipe_account_info_%s'%recipe.id]:
|
||||
error_dialog(self, _('Must set account information'), _('This recipe requires a username and password')).exec_()
|
||||
error_dialog(self, _('Must set account information'),
|
||||
_('This recipe requires a username and password')).exec_()
|
||||
self.schedule.setCheckState(Qt.Unchecked)
|
||||
return
|
||||
if self.interval_button.isChecked():
|
||||
@ -350,9 +351,11 @@ class SchedulerDialog(QDialog, Ui_Dialog):
|
||||
self.username.blockSignals(False)
|
||||
self.password.blockSignals(False)
|
||||
d = datetime.utcnow() - recipe.last_downloaded
|
||||
ld = '%.2f'%(d.days + d.seconds/(24.*3600))
|
||||
def hm(x): return (x-x%3600)//3600, (x%3600 - (x%3600)%60)//60
|
||||
hours, minutes = hm(d.seconds)
|
||||
tm = _('%d days, %d hours and %d minutes ago')%(d.days, hours, minutes)
|
||||
if d < timedelta(days=366):
|
||||
self.last_downloaded.setText(_('Last downloaded: %s days ago')%ld)
|
||||
self.last_downloaded.setText(_('Last downloaded')+': '+tm)
|
||||
else:
|
||||
self.last_downloaded.setText(_('Last downloaded: never'))
|
||||
|
||||
@ -431,7 +434,7 @@ class Scheduler(QObject):
|
||||
day_matches = day > 6 or day == now.tm_wday
|
||||
tnow = now.tm_hour*60 + now.tm_min
|
||||
matches = day_matches and (hour*60+minute) < tnow
|
||||
if matches and delta >= timedelta(days=1):
|
||||
if matches and nowt.toordinal() < date.today().toordinal():
|
||||
needs_downloading.add(recipe)
|
||||
|
||||
self.debug('Needs downloading:', needs_downloading)
|
||||
|
@ -5,7 +5,7 @@
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>726</width>
|
||||
<width>738</width>
|
||||
<height>575</height>
|
||||
</rect>
|
||||
</property>
|
||||
@ -194,6 +194,9 @@
|
||||
<property name="text" >
|
||||
<string/>
|
||||
</property>
|
||||
<property name="wordWrap" >
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
|
@ -20,4 +20,5 @@ class BookView(QGraphicsView):
|
||||
|
||||
def resize_for(self, width, height):
|
||||
self.preferred_size = QSize(width, height)
|
||||
|
||||
|
@ -80,8 +80,8 @@ class Main(MainWindow, Ui_MainWindow):
|
||||
|
||||
QObject.connect(self.search, SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'), self.find)
|
||||
|
||||
self.action_next_page.setShortcuts(QKeySequence.MoveToNextPage)
|
||||
self.action_previous_page.setShortcuts(QKeySequence.MoveToPreviousPage)
|
||||
self.action_next_page.setShortcuts([QKeySequence.MoveToNextPage, QKeySequence(Qt.Key_Space)])
|
||||
self.action_previous_page.setShortcuts([QKeySequence.MoveToPreviousPage, QKeySequence(Qt.Key_Backspace)])
|
||||
self.action_next_match.setShortcuts(QKeySequence.FindNext)
|
||||
self.addAction(self.action_next_match)
|
||||
QObject.connect(self.action_next_page, SIGNAL('triggered(bool)'), self.next)
|
||||
@ -191,6 +191,7 @@ class Main(MainWindow, Ui_MainWindow):
|
||||
self.spin_box.setSuffix(' of %d'%(self.document.num_of_pages,))
|
||||
self.spin_box.updateGeometry()
|
||||
self.stack.setCurrentIndex(0)
|
||||
self.graphics_view.setFocus(Qt.OtherFocusReason)
|
||||
elif self.renderer.exception is not None:
|
||||
exception = self.renderer.exception
|
||||
print >>sys.stderr, 'Error rendering document'
|
||||
|
@ -35,7 +35,9 @@ class DebugWindow(ConversionErrorDialog):
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
|
||||
_menu_bar = None
|
||||
___menu_bar = None
|
||||
___menu = None
|
||||
__actions = []
|
||||
|
||||
@classmethod
|
||||
def create_application_menubar(cls):
|
||||
@ -43,9 +45,11 @@ class MainWindow(QMainWindow):
|
||||
menu = QMenu()
|
||||
for action in cls.get_menubar_actions():
|
||||
menu.addAction(action)
|
||||
cls.__actions.append(action)
|
||||
yield action
|
||||
mb.addMenu(menu)
|
||||
cls._menu_bar = mb
|
||||
cls.___menu_bar = mb
|
||||
cls.___menu = menu
|
||||
|
||||
|
||||
@classmethod
|
||||
|
@ -299,10 +299,10 @@ To learn more about writing advanced recipes using some of the facilities, avail
|
||||
:ref:`API Documentation <news_recipe>`
|
||||
Documentation of the ``BasicNewsRecipe`` class and all its important methods and fields.
|
||||
|
||||
`BasicNewsRecipe <http://bazaar.launchpad.net/~kovid/calibre/trunk/annotate/kovid%40kovidgoyal.net-20080509231359-le3xf7ynwc6eew90?file_id=1245%40b0dd1a5d-880a-0410-ada5-a57097536bc1%3Alibprs500%252Ftrunk%3Asrc%252Flibprs500%252Fweb%252Ffeeds%252Fnews.py>`_
|
||||
`BasicNewsRecipe <http://bazaar.launchpad.net/~kovid/calibre/trunk/annotate/head:/src/calibre/web/feeds/news.py>`_
|
||||
The source code of ``BasicNewsRecipe``
|
||||
|
||||
`Built-in recipes <http://bazaar.launchpad.net/~kovid/calibre/trunk/files/kovid%40kovidgoyal.net-20080509231359-le3xf7ynwc6eew90?file_id=1298%40b0dd1a5d-880a-0410-ada5-a57097536bc1%3Alibprs500%252Ftrunk%3Asrc%252Flibprs500%252Fweb%252Ffeeds%252Frecipes>`_
|
||||
`Built-in recipes <http://bazaar.launchpad.net/~kovid/calibre/trunk/files/head:/src/calibre/web/feeds/recipes/>`_
|
||||
The source code for the built-in recipes that come with |app|
|
||||
|
||||
Migrating old style profiles to recipes
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -32,3 +32,8 @@ class LondonReviewOfBooks(BasicNewsRecipe):
|
||||
def print_version(self, url):
|
||||
main, split, rest = url.rpartition('/')
|
||||
return main + '/print/' + rest
|
||||
|
||||
def postprocess_html(self, soup, first_fetch):
|
||||
for t in soup.findAll(['table', 'tr', 'td']):
|
||||
t.name = 'div'
|
||||
return soup
|
||||
|
3927
src/odf/grammar.py
3927
src/odf/grammar.py
File diff suppressed because it is too large
Load Diff
@ -39,15 +39,3 @@ def Algorithm(**args):
|
||||
def KeyDerivation(**args):
|
||||
return Element(qname = (MANIFESTNS,'key-derivation'), **args)
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import cStringIO
|
||||
xml=cStringIO.StringIO()
|
||||
m = Manifest()
|
||||
f = FileEntry(mediatype="text/xml", fullpath="content.xml")
|
||||
m.addElement(f)
|
||||
|
||||
m.toXml(0,xml)
|
||||
print xml.getvalue()
|
||||
|
||||
|
@ -17,7 +17,7 @@
|
||||
#
|
||||
# Contributor(s):
|
||||
#
|
||||
TOOLSVERSION = u"ODFPY/0.8.1dev"
|
||||
TOOLSVERSION = u"ODFPY/0.8.2dev"
|
||||
|
||||
ANIMNS = u"urn:oasis:names:tc:opendocument:xmlns:animation:1.0"
|
||||
DBNS = u"urn:oasis:names:tc:opendocument:xmlns:database:1.0"
|
||||
@ -49,7 +49,7 @@ TABLENS = u"urn:oasis:names:tc:opendocument:xmlns:table:1.0"
|
||||
TEXTNS = u"urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
||||
XFORMSNS = u"http://www.w3.org/2002/xforms"
|
||||
XLINKNS = u"http://www.w3.org/1999/xlink"
|
||||
XMLNS = "http://www.w3.org/XML/1998/namespace"
|
||||
XMLNS = u"http://www.w3.org/XML/1998/namespace"
|
||||
|
||||
|
||||
nsdict = {
|
||||
|
@ -64,6 +64,12 @@ odmimetypes = {
|
||||
'application/vnd.oasis.opendocument.text-web': '.oth',
|
||||
}
|
||||
|
||||
class OpaqueObject:
|
||||
def __init__(self, filename, mediatype, content=None):
|
||||
self.mediatype = mediatype
|
||||
self.filename = filename
|
||||
self.content = content
|
||||
|
||||
class OpenDocument:
|
||||
""" A class to hold the content of an OpenDocument document
|
||||
Use the xml method to write the XML
|
||||
@ -76,6 +82,7 @@ class OpenDocument:
|
||||
def __init__(self, mimetype, add_generator=True):
|
||||
self.mimetype = mimetype
|
||||
self.childobjects = []
|
||||
self._extra = []
|
||||
self.folder = "" # Always empty for toplevel documents
|
||||
self.topnode = Document(mimetype=self.mimetype)
|
||||
self.topnode.ownerDocument = self
|
||||
@ -303,12 +310,15 @@ class OpenDocument:
|
||||
else:
|
||||
self.thumbnail = filecontent
|
||||
|
||||
def addObject(self, document):
|
||||
def addObject(self, document, objectname=None):
|
||||
""" Add an object. The object must be an OpenDocument class
|
||||
The return value will be the folder in the zipfile the object is stored in
|
||||
"""
|
||||
self.childobjects.append(document)
|
||||
document.folder = "%s/Object %d" % (self.folder, len(self.childobjects))
|
||||
if objectname is None:
|
||||
document.folder = "%s/Object %d" % (self.folder, len(self.childobjects))
|
||||
else:
|
||||
document.folder = objectname
|
||||
return ".%s" % document.folder
|
||||
|
||||
def _savePictures(self, object, folder):
|
||||
@ -348,7 +358,7 @@ class OpenDocument:
|
||||
else:
|
||||
if addsuffix:
|
||||
outputfile = outputfile + odmimetypes.get(self.mimetype,'.xxx')
|
||||
outputfp = zipfile.ZipFile(outputfile,"w")
|
||||
outputfp = zipfile.ZipFile(outputfile, "w")
|
||||
self._zipwrite(outputfp)
|
||||
outputfp.close()
|
||||
|
||||
@ -382,6 +392,14 @@ class OpenDocument:
|
||||
zi.external_attr = UNIXPERMS
|
||||
self._z.writestr(zi, self.thumbnail)
|
||||
|
||||
# Write any extra files
|
||||
for op in self._extra:
|
||||
self.manifest.addElement(manifest.FileEntry(fullpath=op.filename, mediatype=op.mediatype))
|
||||
zi = zipfile.ZipInfo(op.filename.encode('utf-8'), self._now)
|
||||
zi.compress_type = zipfile.ZIP_DEFLATED
|
||||
zi.external_attr = UNIXPERMS
|
||||
if op.content is not None:
|
||||
self._z.writestr(zi, op.content)
|
||||
# Write manifest
|
||||
zi = zipfile.ZipInfo("META-INF/manifest.xml", self._now)
|
||||
zi.compress_type = zipfile.ZIP_DEFLATED
|
||||
@ -528,15 +546,20 @@ def load(odffile):
|
||||
parser.parse(inpsrc)
|
||||
del doc._parsing
|
||||
except KeyError, v: pass
|
||||
# Add the thumbnail here
|
||||
# Add the images here
|
||||
# FIXME: Add subobjects correctly here
|
||||
for mentry,mvalue in manifest.items():
|
||||
if mentry[:9] == "Pictures/" and len(mentry) > 9:
|
||||
doc.addPicture(mvalue['full-path'], mvalue['media-type'], z.read(mentry))
|
||||
elif mentry == "Thumbnails/thumbnail.png":
|
||||
doc.addThumbnail(z.read(mentry))
|
||||
elif mentry in ('settings.xml', 'meta.xml', 'content.xml', 'styles.xml'):
|
||||
pass
|
||||
else:
|
||||
pass # Add the SUN junk here to the struct somewhere
|
||||
if mvalue['full-path'][-1] == '/':
|
||||
doc._extra.append(OpaqueObject(mvalue['full-path'], mvalue['media-type'], None))
|
||||
else:
|
||||
doc._extra.append(OpaqueObject(mvalue['full-path'], mvalue['media-type'], z.read(mentry)))
|
||||
# Add the SUN junk here to the struct somewhere
|
||||
# It is cached data, so it can be out-of-date
|
||||
z.close()
|
||||
b = doc.getElementsByType(Body)
|
||||
|
@ -231,6 +231,19 @@ class ODFContentParser(xml.sax.saxutils.XMLGenerator):
|
||||
self._callback_func = callback_func
|
||||
xml.sax.saxutils.XMLGenerator.__init__(self, out, encoding)
|
||||
|
||||
def _qname(self, name):
|
||||
"""Builds a qualified name from a (ns_url, localname) pair"""
|
||||
if name[0]:
|
||||
if name[0] == u'http://www.w3.org/XML/1998/namespace':
|
||||
return u'xml' + ":" + name[1]
|
||||
# The name is in a non-empty namespace
|
||||
prefix = self._current_context[name[0]]
|
||||
if prefix:
|
||||
# If it is not the default namespace, prepend the prefix
|
||||
return prefix + ":" + name[1]
|
||||
# Return the unqualified name
|
||||
return name[1]
|
||||
|
||||
def startElementNS(self, name, qname, attrs):
|
||||
if name == (TEXTNS, u'user-field-decl'):
|
||||
field_name = attrs.get((TEXTNS, u'name'))
|
||||
|
Loading…
x
Reference in New Issue
Block a user