Adding new entries to the ToC implemented

This commit is contained in:
Kovid Goyal 2013-03-13 13:20:52 +05:30
parent 086ede3705
commit 4928433f75
4 changed files with 217 additions and 44 deletions

View File

@ -222,6 +222,10 @@ class Container(object):
self.encoding_map[name] = self.used_encoding self.encoding_map[name] = self.used_encoding
return ans return ans
def replace(self, name, obj):
self.parsed_cache[name] = obj
self.dirty(name)
@property @property
def opf(self): def opf(self):
return self.parsed(self.opf_name) return self.parsed(self.opf_name)
@ -417,11 +421,12 @@ class Container(object):
data = re.sub(br'(<[/]{0,1})opf:', r'\1', data) data = re.sub(br'(<[/]{0,1})opf:', r'\1', data)
return data return data
def commit_item(self, name): def commit_item(self, name, keep_parsed=False):
if name not in self.parsed_cache: if name not in self.parsed_cache:
return return
data = self.serialize_item(name) data = self.serialize_item(name)
self.dirtied.remove(name) self.dirtied.discard(name)
if not keep_parsed:
self.parsed_cache.pop(name) self.parsed_cache.pop(name)
with open(self.name_path_map[name], 'wb') as f: with open(self.name_path_map[name], 'wb') as f:
f.write(data) f.write(data)

View File

@ -7,17 +7,23 @@ __license__ = 'GPL v3'
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>' __copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
__docformat__ = 'restructuredtext en' __docformat__ = 'restructuredtext en'
import re
from urlparse import urlparse from urlparse import urlparse
from collections import deque
from functools import partial
from lxml import etree from lxml import etree
from calibre.ebooks.oeb.base import XPath from calibre import __version__
from calibre.ebooks.oeb.base import XPath, uuid_id, xml2text, NCX, NCX_NS, XML
from calibre.ebooks.oeb.polish.container import guess_type from calibre.ebooks.oeb.polish.container import guess_type
from calibre.utils.localization import get_lang, canonicalize_lang, lang_as_iso639_1
ns = etree.FunctionNamespace('calibre_xpath_extensions') ns = etree.FunctionNamespace('calibre_xpath_extensions')
ns.prefix = 'calibre' ns.prefix = 'calibre'
ns['lower-case'] = lambda c, x: x.lower() if hasattr(x, 'lower') else x ns['lower-case'] = lambda c, x: x.lower() if hasattr(x, 'lower') else x
class TOC(object): class TOC(object):
def __init__(self, title=None, dest=None, frag=None): def __init__(self, title=None, dest=None, frag=None):
@ -43,9 +49,18 @@ class TOC(object):
for gc in child.iterdescendants(): for gc in child.iterdescendants():
yield gc yield gc
@property
def depth(self):
"""The maximum depth of the navigation tree rooted at this node."""
try:
return max(node.depth for node in self) + 1
except ValueError:
return 1
def child_xpath(tag, name): def child_xpath(tag, name):
return tag.xpath('./*[calibre:lower-case(local-name()) = "%s"]'%name) return tag.xpath('./*[calibre:lower-case(local-name()) = "%s"]'%name)
def add_from_navpoint(container, navpoint, parent, ncx_name): def add_from_navpoint(container, navpoint, parent, ncx_name):
dest = frag = text = None dest = frag = text = None
nl = child_xpath(navpoint, 'navlabel') nl = child_xpath(navpoint, 'navlabel')
@ -64,20 +79,32 @@ def add_from_navpoint(container, navpoint, parent, ncx_name):
frag = urlparse(href).fragment or None frag = urlparse(href).fragment or None
return parent.add(text or None, dest or None, frag or None) return parent.add(text or None, dest or None, frag or None)
def process_ncx_node(container, node, toc_parent, ncx_name): def process_ncx_node(container, node, toc_parent, ncx_name):
for navpoint in node.xpath('./*[calibre:lower-case(local-name()) = "navpoint"]'): for navpoint in node.xpath('./*[calibre:lower-case(local-name()) = "navpoint"]'):
child = add_from_navpoint(container, navpoint, toc_parent, ncx_name) child = add_from_navpoint(container, navpoint, toc_parent, ncx_name)
if child is not None: if child is not None:
process_ncx_node(container, navpoint, child, ncx_name) process_ncx_node(container, navpoint, child, ncx_name)
def parse_ncx(container, ncx_name): def parse_ncx(container, ncx_name):
root = container.parsed(ncx_name) root = container.parsed(ncx_name)
toc_root = TOC() toc_root = TOC()
navmaps = root.xpath('//*[calibre:lower-case(local-name()) = "navmap"]') navmaps = root.xpath('//*[calibre:lower-case(local-name()) = "navmap"]')
if navmaps: if navmaps:
process_ncx_node(container, navmaps[0], toc_root, ncx_name) process_ncx_node(container, navmaps[0], toc_root, ncx_name)
toc_root.lang = toc_root.uid = None
for attr, val in root.attrib.iteritems():
if attr.endswith('lang'):
toc_root.lang = unicode(val)
break
for uid in root.xpath('//*[calibre:lower-case(local-name()) = "meta" and @name="dtb:uid"]/@content'):
if uid:
toc_root.uid = unicode(uid)
break
return toc_root return toc_root
def verify_toc_destinations(container, toc): def verify_toc_destinations(container, toc):
anchor_map = {} anchor_map = {}
anchor_xpath = XPath('//*/@id|//h:a/@name') anchor_xpath = XPath('//*/@id|//h:a/@name')
@ -108,7 +135,8 @@ def verify_toc_destinations(container, toc):
'The anchor %(a)s does not exist in file %(f)s')%dict( 'The anchor %(a)s does not exist in file %(f)s')%dict(
a=item.frag, f=name) a=item.frag, f=name)
def get_toc(container, verify_destinations=True):
def find_existing_toc(container):
toc = container.opf_xpath('//opf:spine/@toc') toc = container.opf_xpath('//opf:spine/@toc')
if toc: if toc:
toc = container.manifest_id_map.get(toc[0], None) toc = container.manifest_id_map.get(toc[0], None)
@ -117,9 +145,107 @@ def get_toc(container, verify_destinations=True):
toc = container.manifest_type_map.get(ncx, [None])[0] toc = container.manifest_type_map.get(ncx, [None])[0]
if not toc: if not toc:
return None return None
return toc
def get_toc(container, verify_destinations=True):
toc = find_existing_toc(container)
if toc is None:
ans = TOC()
ans.lang = ans.uid = None
return ans
ans = parse_ncx(container, toc) ans = parse_ncx(container, toc)
if verify_destinations: if verify_destinations:
verify_toc_destinations(container, ans) verify_toc_destinations(container, ans)
return ans return ans
def add_id(container, name, loc):
root = container.parsed(name)
body = root.xpath('//*[local-name()="body"]')[0]
locs = deque(loc)
node = body
while locs:
children = tuple(node.iterchildren(etree.Element))
node = children[locs[0]]
locs.popleft()
node.set('id', node.get('id', uuid_id()))
container.commit_item(name, keep_parsed=True)
return node.get('id')
def create_ncx(toc, to_href, btitle, lang, uid):
lang = lang.replace('_', '-')
ncx = etree.Element(NCX('ncx'),
attrib={'version': '2005-1', XML('lang'): lang},
nsmap={None: NCX_NS})
head = etree.SubElement(ncx, NCX('head'))
etree.SubElement(head, NCX('meta'),
name='dtb:uid', content=unicode(uid))
etree.SubElement(head, NCX('meta'),
name='dtb:depth', content=str(toc.depth))
generator = ''.join(['calibre (', __version__, ')'])
etree.SubElement(head, NCX('meta'),
name='dtb:generator', content=generator)
etree.SubElement(head, NCX('meta'), name='dtb:totalPageCount', content='0')
etree.SubElement(head, NCX('meta'), name='dtb:maxPageNumber', content='0')
title = etree.SubElement(ncx, NCX('docTitle'))
text = etree.SubElement(title, NCX('text'))
text.text = btitle
navmap = etree.SubElement(ncx, NCX('navMap'))
spat = re.compile(r'\s+')
def process_node(xml_parent, toc_parent, play_order=0):
for child in toc_parent:
play_order += 1
point = etree.SubElement(xml_parent, NCX('navPoint'), id=uuid_id(),
playOrder=str(play_order))
label = etree.SubElement(point, NCX('navLabel'))
title = child.title
if title:
title = spat.sub(' ', title)
etree.SubElement(label, NCX('text')).text = title
if child.dest:
href = to_href(child.dest)
if child.frag:
href += '#'+child.frag
etree.SubElement(point, NCX('content'), src=href)
process_node(point, child, play_order)
process_node(navmap, toc)
return ncx
def commit_toc(container, toc, lang=None, uid=None):
tocname = find_existing_toc(container)
if tocname is None:
item = container.generate_item('toc.ncx', id_prefix='toc')
tocname = container.href_to_name(item.get('href'),
base=container.opf_name)
if not lang:
lang = get_lang()
for l in container.opf_xpath('//dc:language'):
l = canonicalize_lang(xml2text(l).strip())
if l:
lang = l
lang = lang_as_iso639_1(l) or l
break
lang = lang_as_iso639_1(lang) or lang
if not uid:
uid = uuid_id()
eid = container.opf.get('unique-identifier', None)
if eid:
m = container.opf_xpath('//*[@id="%s"]'%eid)
if m:
uid = xml2text(m[0])
title = _('Table of Contents')
m = container.opf_xpath('//dc:title')
if m:
x = xml2text(m[0]).strip()
title = x or title
to_href = partial(container.name_to_href, base=tocname)
root = create_ncx(toc, to_href, title, lang, uid)
container.replace(tocname, root)

View File

@ -131,15 +131,14 @@ class ItemEdit(QWidget):
la.setWordWrap(True) la.setWordWrap(True)
l.addWidget(la) l.addWidget(la)
f.la2 = la = QLabel(_('&Name of the ToC entry:')) f.la2 = la = QLabel('<b>'+_('&Name of the ToC entry:'))
l.addWidget(la) l.addWidget(la)
self.name = QLineEdit(self) self.name = QLineEdit(self)
la.setBuddy(self.name) la.setBuddy(self.name)
l.addWidget(self.name) l.addWidget(self.name)
self.base_msg = _('Currently selected destination:') self.base_msg = '<b>'+_('Currently selected destination:')+'</b>'
self.dest_label = la = QLabel(self.base_msg) self.dest_label = la = QLabel(self.base_msg)
la.setTextFormat(Qt.PlainText)
la.setWordWrap(True) la.setWordWrap(True)
la.setStyleSheet('QLabel { margin-top: 20px }') la.setStyleSheet('QLabel { margin-top: 20px }')
l.addWidget(la) l.addWidget(la)
@ -163,8 +162,8 @@ class ItemEdit(QWidget):
load_html(path, self.view, codec=encoding, load_html(path, self.view, codec=encoding,
mime_type=self.container.mime_map[name]) mime_type=self.container.mime_map[name])
self.view.load_js() self.view.load_js()
self.dest_label.setText(self.base_msg + '\n' + _('File:') + ' ' + self.dest_label.setText(self.base_msg + '<br>' + _('File:') + ' ' +
name + '\n' + _('Top of the file')) name + '<br>' + _('Top of the file'))
def __call__(self, item, where): def __call__(self, item, where):
self.current_item, self.current_where = item, where self.current_item, self.current_where = item, where
@ -173,23 +172,22 @@ class ItemEdit(QWidget):
if item is None: if item is None:
self.dest_list.setCurrentRow(0) self.dest_list.setCurrentRow(0)
self.name.setText(_('(Untitled)')) self.name.setText(_('(Untitled)'))
self.dest_label.setText(self.base_msg + '\n' + _('None'))
def elem_clicked(self, tag, frac, elem_id, loc): def elem_clicked(self, tag, frac, elem_id, loc):
self.current_frag = elem_id or loc self.current_frag = elem_id or loc
frac = int(round(frac * 100)) frac = int(round(frac * 100))
base = _('Location: A <%s> tag inside the file')%tag base = _('Location: A &lt;%s&gt; tag inside the file')%tag
if frac == 0: if frac == 0:
loctext = _('Top of the file') loctext = _('Top of the file')
else: else:
loctext = _('Approximately %d%% from the top')%frac loctext = _('Approximately %d%% from the top')%frac
loctext = base + ' [%s]'%loctext loctext = base + ' [%s]'%loctext
self.dest_label.setText(self.base_msg + '\n' + self.dest_label.setText(self.base_msg + '<br>' +
_('File:') + ' ' + self.current_name + '\n' + loctext) _('File:') + ' ' + self.current_name + '<br>' + loctext)
@property @property
def result(self): def result(self):
return (self.current_item, self.current_where, self.current_name, return (self.current_item, self.current_where, self.current_name,
self.current_frag) self.current_frag, unicode(self.name.text()))

View File

@ -16,7 +16,7 @@ from PyQt4.Qt import (QPushButton, QFrame,
QToolButton, QItemSelectionModel) QToolButton, QItemSelectionModel)
from calibre.ebooks.oeb.polish.container import get_container from calibre.ebooks.oeb.polish.container import get_container
from calibre.ebooks.oeb.polish.toc import get_toc from calibre.ebooks.oeb.polish.toc import get_toc, add_id, TOC, commit_toc
from calibre.gui2 import Application from calibre.gui2 import Application
from calibre.gui2.progress_indicator import ProgressIndicator from calibre.gui2.progress_indicator import ProgressIndicator
from calibre.gui2.toc.location import ItemEdit from calibre.gui2.toc.location import ItemEdit
@ -191,6 +191,7 @@ class TOCView(QWidget): # {{{
def update_status_tip(self, item): def update_status_tip(self, item):
c = item.data(0, Qt.UserRole).toPyObject() c = item.data(0, Qt.UserRole).toPyObject()
if c is not None:
frag = c.frag or '' frag = c.frag or ''
if frag: if frag:
frag = '#'+frag frag = '#'+frag
@ -202,32 +203,38 @@ class TOCView(QWidget): # {{{
idx = self.tocw.model().index(r, 0, top_left.parent()) idx = self.tocw.model().index(r, 0, top_left.parent())
new_title = unicode(idx.data(Qt.DisplayRole).toString()).strip() new_title = unicode(idx.data(Qt.DisplayRole).toString()).strip()
toc = idx.data(Qt.UserRole).toPyObject() toc = idx.data(Qt.UserRole).toPyObject()
if toc is not None:
toc.title = new_title or _('(Untitled)') toc.title = new_title or _('(Untitled)')
item = self.tocw.itemFromIndex(idx) item = self.tocw.itemFromIndex(idx)
self.update_status_tip(item) self.update_status_tip(item)
def __call__(self, ebook): def create_item(self, parent, child):
self.ebook = ebook
self.toc = get_toc(self.ebook)
blank = self.blank = QIcon(I('blank.png'))
ok = self.ok = QIcon(I('ok.png'))
err = self.err = QIcon(I('dot_red.png'))
icon_map = {None:blank, True:ok, False:err}
def process_item(node, parent):
for child in node:
c = QTreeWidgetItem(parent) c = QTreeWidgetItem(parent)
c.setData(0, Qt.DisplayRole, child.title or _('(Untitled)')) c.setData(0, Qt.DisplayRole, child.title or _('(Untitled)'))
c.setData(0, Qt.UserRole, child) c.setData(0, Qt.UserRole, child)
c.setFlags(Qt.ItemIsDragEnabled|Qt.ItemIsEditable|Qt.ItemIsEnabled| c.setFlags(Qt.ItemIsDragEnabled|Qt.ItemIsEditable|Qt.ItemIsEnabled|
Qt.ItemIsSelectable|Qt.ItemIsDropEnabled) Qt.ItemIsSelectable|Qt.ItemIsDropEnabled)
c.setData(0, Qt.DecorationRole, icon_map[child.dest_exists]) c.setData(0, Qt.DecorationRole, self.icon_map[child.dest_exists])
if child.dest_exists is False: if child.dest_exists is False:
c.setData(0, Qt.ToolTipRole, _( c.setData(0, Qt.ToolTipRole, _(
'The location this entry point to does not exist:\n%s') 'The location this entry point to does not exist:\n%s')
%child.dest_error) %child.dest_error)
self.update_status_tip(c) self.update_status_tip(c)
return c
def __call__(self, ebook):
self.ebook = ebook
self.toc = get_toc(self.ebook)
self.toc_lang, self.toc_uid = self.toc.lang, self.toc.uid
self.blank = QIcon(I('blank.png'))
self.ok = QIcon(I('ok.png'))
self.err = QIcon(I('dot_red.png'))
self.icon_map = {None:self.blank, True:self.ok, False:self.err}
def process_item(toc_node, parent):
for child in toc_node:
c = self.create_item(parent, child)
process_item(child, c) process_item(child, c)
root = self.root = self.tocw.invisibleRootItem() root = self.root = self.tocw.invisibleRootItem()
@ -235,10 +242,38 @@ class TOCView(QWidget): # {{{
process_item(self.toc, root) process_item(self.toc, root)
self.tocw.model().dataChanged.connect(self.data_changed) self.tocw.model().dataChanged.connect(self.data_changed)
self.tocw.currentItemChanged.connect(self.current_item_changed) self.tocw.currentItemChanged.connect(self.current_item_changed)
self.tocw.setCurrentItem(None)
def current_item_changed(self, current, previous): def current_item_changed(self, current, previous):
self.item_view(current) self.item_view(current)
def update_item(self, item, where, name, frag, title):
if isinstance(frag, tuple):
frag = add_id(self.ebook, name, frag)
if item is None:
if where is None:
where = self.tocw.invisibleRootItem()
child = TOC(title, name, frag)
child.dest_exists = True
c = self.create_item(where, child)
self.tocw.setCurrentItem(c, 0, QItemSelectionModel.ClearAndSelect)
self.tocw.scrollToItem(c)
def create_toc(self):
root = TOC()
def process_node(parent, toc_parent):
for i in xrange(parent.childCount()):
item = parent.child(i)
title = unicode(item.data(0, Qt.DisplayRole).toString()).strip()
toc = item.data(0, Qt.UserRole).toPyObject()
dest, frag = toc.dest, toc.frag
toc = toc_parent.add(title, dest, frag)
process_node(item, toc)
process_node(self.tocw.invisibleRootItem(), root)
return root
# }}} # }}}
class TOCEditor(QDialog): # {{{ class TOCEditor(QDialog): # {{{
@ -283,6 +318,7 @@ class TOCEditor(QDialog): # {{{
self.explode_done.connect(self.read_toc, type=Qt.QueuedConnection) self.explode_done.connect(self.read_toc, type=Qt.QueuedConnection)
self.resize(950, 630) self.resize(950, 630)
self.working = True
def add_new_item(self, item, where): def add_new_item(self, item, where):
self.item_edit(item, where) self.item_edit(item, where)
@ -290,15 +326,18 @@ class TOCEditor(QDialog): # {{{
def accept(self): def accept(self):
if self.stacks.currentIndex() == 2: if self.stacks.currentIndex() == 2:
self.toc_view.update_item(self.item_edit.result) self.toc_view.update_item(*self.item_edit.result)
self.stacks.setCurrentIndex(1) self.stacks.setCurrentIndex(1)
else: else:
self.write_toc()
self.working = False
super(TOCEditor, self).accept() super(TOCEditor, self).accept()
def reject(self): def reject(self):
if self.stacks.currentIndex() == 2: if self.stacks.currentIndex() == 2:
self.stacks.setCurrentIndex(1) self.stacks.setCurrentIndex(1)
else: else:
self.working = False
super(TOCEditor, self).accept() super(TOCEditor, self).accept()
def start(self): def start(self):
@ -309,8 +348,7 @@ class TOCEditor(QDialog): # {{{
def explode(self): def explode(self):
self.ebook = get_container(self.pathtobook, log=self.log) self.ebook = get_container(self.pathtobook, log=self.log)
if not self.isVisible(): if self.working:
return
self.explode_done.emit() self.explode_done.emit()
def read_toc(self): def read_toc(self):
@ -319,6 +357,12 @@ class TOCEditor(QDialog): # {{{
self.item_edit.load(self.ebook) self.item_edit.load(self.ebook)
self.stacks.setCurrentIndex(1) self.stacks.setCurrentIndex(1)
def write_toc(self):
toc = self.toc_view.create_toc()
commit_toc(self.ebook, toc, lang=self.toc_view.toc_lang,
uid=self.toc_view.toc_uid)
self.ebook.commit()
# }}} # }}}
if __name__ == '__main__': if __name__ == '__main__':