diff --git a/src/calibre/ebooks/oeb/polish/container.py b/src/calibre/ebooks/oeb/polish/container.py index dfdc09f373..e359c83f19 100644 --- a/src/calibre/ebooks/oeb/polish/container.py +++ b/src/calibre/ebooks/oeb/polish/container.py @@ -222,6 +222,10 @@ class Container(object): self.encoding_map[name] = self.used_encoding return ans + def replace(self, name, obj): + self.parsed_cache[name] = obj + self.dirty(name) + @property def opf(self): return self.parsed(self.opf_name) @@ -417,12 +421,13 @@ class Container(object): data = re.sub(br'(<[/]{0,1})opf:', r'\1', data) return data - def commit_item(self, name): + def commit_item(self, name, keep_parsed=False): if name not in self.parsed_cache: return data = self.serialize_item(name) - self.dirtied.remove(name) - self.parsed_cache.pop(name) + self.dirtied.discard(name) + if not keep_parsed: + self.parsed_cache.pop(name) with open(self.name_path_map[name], 'wb') as f: f.write(data) diff --git a/src/calibre/ebooks/oeb/polish/toc.py b/src/calibre/ebooks/oeb/polish/toc.py index 26faabeedb..fcd047f7ad 100644 --- a/src/calibre/ebooks/oeb/polish/toc.py +++ b/src/calibre/ebooks/oeb/polish/toc.py @@ -7,17 +7,23 @@ __license__ = 'GPL v3' __copyright__ = '2013, Kovid Goyal ' __docformat__ = 'restructuredtext en' +import re from urlparse import urlparse +from collections import deque +from functools import partial 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.utils.localization import get_lang, canonicalize_lang, lang_as_iso639_1 ns = etree.FunctionNamespace('calibre_xpath_extensions') ns.prefix = 'calibre' ns['lower-case'] = lambda c, x: x.lower() if hasattr(x, 'lower') else x + class TOC(object): def __init__(self, title=None, dest=None, frag=None): @@ -43,9 +49,18 @@ class TOC(object): for gc in child.iterdescendants(): 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): return tag.xpath('./*[calibre:lower-case(local-name()) = "%s"]'%name) + def add_from_navpoint(container, navpoint, parent, ncx_name): dest = frag = text = None nl = child_xpath(navpoint, 'navlabel') @@ -64,20 +79,32 @@ def add_from_navpoint(container, navpoint, parent, ncx_name): frag = urlparse(href).fragment or None return parent.add(text or None, dest or None, frag or None) + def process_ncx_node(container, node, toc_parent, ncx_name): for navpoint in node.xpath('./*[calibre:lower-case(local-name()) = "navpoint"]'): child = add_from_navpoint(container, navpoint, toc_parent, ncx_name) if child is not None: process_ncx_node(container, navpoint, child, ncx_name) + def parse_ncx(container, ncx_name): root = container.parsed(ncx_name) toc_root = TOC() navmaps = root.xpath('//*[calibre:lower-case(local-name()) = "navmap"]') if navmaps: 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 + def verify_toc_destinations(container, toc): anchor_map = {} 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( a=item.frag, f=name) -def get_toc(container, verify_destinations=True): + +def find_existing_toc(container): toc = container.opf_xpath('//opf:spine/@toc') if toc: 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] if not toc: 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) if verify_destinations: verify_toc_destinations(container, 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) + diff --git a/src/calibre/gui2/toc/location.py b/src/calibre/gui2/toc/location.py index e45e9c025f..c275425e0a 100644 --- a/src/calibre/gui2/toc/location.py +++ b/src/calibre/gui2/toc/location.py @@ -131,15 +131,14 @@ class ItemEdit(QWidget): la.setWordWrap(True) l.addWidget(la) - f.la2 = la = QLabel(_('&Name of the ToC entry:')) + f.la2 = la = QLabel(''+_('&Name of the ToC entry:')) l.addWidget(la) self.name = QLineEdit(self) la.setBuddy(self.name) l.addWidget(self.name) - self.base_msg = _('Currently selected destination:') + self.base_msg = ''+_('Currently selected destination:')+'' self.dest_label = la = QLabel(self.base_msg) - la.setTextFormat(Qt.PlainText) la.setWordWrap(True) la.setStyleSheet('QLabel { margin-top: 20px }') l.addWidget(la) @@ -163,8 +162,8 @@ class ItemEdit(QWidget): load_html(path, self.view, codec=encoding, mime_type=self.container.mime_map[name]) self.view.load_js() - self.dest_label.setText(self.base_msg + '\n' + _('File:') + ' ' + - name + '\n' + _('Top of the file')) + self.dest_label.setText(self.base_msg + '
' + _('File:') + ' ' + + name + '
' + _('Top of the file')) def __call__(self, item, where): self.current_item, self.current_where = item, where @@ -173,23 +172,22 @@ class ItemEdit(QWidget): if item is None: self.dest_list.setCurrentRow(0) self.name.setText(_('(Untitled)')) - self.dest_label.setText(self.base_msg + '\n' + _('None')) def elem_clicked(self, tag, frac, elem_id, loc): self.current_frag = elem_id or loc frac = int(round(frac * 100)) - base = _('Location: A <%s> tag inside the file')%tag + base = _('Location: A <%s> tag inside the file')%tag if frac == 0: loctext = _('Top of the file') else: loctext = _('Approximately %d%% from the top')%frac loctext = base + ' [%s]'%loctext - self.dest_label.setText(self.base_msg + '\n' + - _('File:') + ' ' + self.current_name + '\n' + loctext) + self.dest_label.setText(self.base_msg + '
' + + _('File:') + ' ' + self.current_name + '
' + loctext) @property def result(self): return (self.current_item, self.current_where, self.current_name, - self.current_frag) + self.current_frag, unicode(self.name.text())) diff --git a/src/calibre/gui2/toc/main.py b/src/calibre/gui2/toc/main.py index f8df524b1f..16778e57b5 100644 --- a/src/calibre/gui2/toc/main.py +++ b/src/calibre/gui2/toc/main.py @@ -16,7 +16,7 @@ from PyQt4.Qt import (QPushButton, QFrame, QToolButton, QItemSelectionModel) 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.progress_indicator import ProgressIndicator from calibre.gui2.toc.location import ItemEdit @@ -191,43 +191,50 @@ class TOCView(QWidget): # {{{ def update_status_tip(self, item): c = item.data(0, Qt.UserRole).toPyObject() - frag = c.frag or '' - if frag: - frag = '#'+frag - item.setStatusTip(0, _('Title: {0} Dest: {1}{2}').format( - c.title, c.dest, frag)) + if c is not None: + frag = c.frag or '' + if frag: + frag = '#'+frag + item.setStatusTip(0, _('Title: {0} Dest: {1}{2}').format( + c.title, c.dest, frag)) def data_changed(self, top_left, bottom_right): for r in xrange(top_left.row(), bottom_right.row()+1): idx = self.tocw.model().index(r, 0, top_left.parent()) new_title = unicode(idx.data(Qt.DisplayRole).toString()).strip() toc = idx.data(Qt.UserRole).toPyObject() - toc.title = new_title or _('(Untitled)') + if toc is not None: + toc.title = new_title or _('(Untitled)') item = self.tocw.itemFromIndex(idx) self.update_status_tip(item) + def create_item(self, parent, child): + c = QTreeWidgetItem(parent) + c.setData(0, Qt.DisplayRole, child.title or _('(Untitled)')) + c.setData(0, Qt.UserRole, child) + c.setFlags(Qt.ItemIsDragEnabled|Qt.ItemIsEditable|Qt.ItemIsEnabled| + Qt.ItemIsSelectable|Qt.ItemIsDropEnabled) + c.setData(0, Qt.DecorationRole, self.icon_map[child.dest_exists]) + if child.dest_exists is False: + c.setData(0, Qt.ToolTipRole, _( + 'The location this entry point to does not exist:\n%s') + %child.dest_error) + + self.update_status_tip(c) + return c + def __call__(self, ebook): 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} + 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(node, parent): - for child in node: - c = QTreeWidgetItem(parent) - c.setData(0, Qt.DisplayRole, child.title or _('(Untitled)')) - c.setData(0, Qt.UserRole, child) - c.setFlags(Qt.ItemIsDragEnabled|Qt.ItemIsEditable|Qt.ItemIsEnabled| - Qt.ItemIsSelectable|Qt.ItemIsDropEnabled) - c.setData(0, Qt.DecorationRole, icon_map[child.dest_exists]) - if child.dest_exists is False: - c.setData(0, Qt.ToolTipRole, _( - 'The location this entry point to does not exist:\n%s') - %child.dest_error) - - self.update_status_tip(c) + def process_item(toc_node, parent): + for child in toc_node: + c = self.create_item(parent, child) process_item(child, c) root = self.root = self.tocw.invisibleRootItem() @@ -235,10 +242,38 @@ class TOCView(QWidget): # {{{ process_item(self.toc, root) self.tocw.model().dataChanged.connect(self.data_changed) self.tocw.currentItemChanged.connect(self.current_item_changed) + self.tocw.setCurrentItem(None) def current_item_changed(self, current, previous): 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): # {{{ @@ -283,6 +318,7 @@ class TOCEditor(QDialog): # {{{ self.explode_done.connect(self.read_toc, type=Qt.QueuedConnection) self.resize(950, 630) + self.working = True def add_new_item(self, item, where): self.item_edit(item, where) @@ -290,15 +326,18 @@ class TOCEditor(QDialog): # {{{ def accept(self): 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) else: + self.write_toc() + self.working = False super(TOCEditor, self).accept() def reject(self): if self.stacks.currentIndex() == 2: self.stacks.setCurrentIndex(1) else: + self.working = False super(TOCEditor, self).accept() def start(self): @@ -309,9 +348,8 @@ class TOCEditor(QDialog): # {{{ def explode(self): self.ebook = get_container(self.pathtobook, log=self.log) - if not self.isVisible(): - return - self.explode_done.emit() + if self.working: + self.explode_done.emit() def read_toc(self): self.pi.stopAnimation() @@ -319,6 +357,12 @@ class TOCEditor(QDialog): # {{{ self.item_edit.load(self.ebook) 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__':