diff --git a/src/calibre/ebooks/oeb/polish/container.py b/src/calibre/ebooks/oeb/polish/container.py index f8580235c2..66efb697c7 100644 --- a/src/calibre/ebooks/oeb/polish/container.py +++ b/src/calibre/ebooks/oeb/polish/container.py @@ -12,6 +12,7 @@ from collections import defaultdict from io import BytesIO from urllib import unquote as urlunquote, quote as urlquote from urlparse import urlparse +from future_builtins import zip from lxml import etree @@ -228,6 +229,14 @@ class Container(object): # {{{ data, self.used_encoding = xml_to_unicode(data) return fix_data(data) + @property + def names_that_need_not_be_manifested(self): + return {self.opf_name} + + @property + def names_that_must_not_be_removed(self): + return {self.opf_name} + def parse_xml(self, data): data, self.used_encoding = xml_to_unicode( data, strip_encoding_pats=True, assume_utf8=True, resolve_entities=True) @@ -309,9 +318,8 @@ class Container(object): # {{{ for item in self.opf_xpath('//opf:guide/opf:reference[@href and @type]')} @property - def spine_names(self): + def spine_iter(self): manifest_id_map = self.manifest_id_map - non_linear = [] for item in self.opf_xpath('//opf:spine/opf:itemref[@idref]'): idref = item.get('idref') @@ -319,17 +327,34 @@ class Container(object): # {{{ path = self.name_path_map.get(name, None) if path: if item.get('linear', 'yes') == 'yes': - yield name, True + yield item, name, True else: - non_linear.append(name) - for name in non_linear: - yield name, False + non_linear.append((item, name)) + for item, name in non_linear: + yield item, name, False + + @property + def spine_names(self): + for item, name, linear in self.spine_iter: + yield name, linear @property def spine_items(self): for name, linear in self.spine_names: yield self.name_path_map[name] + def remove_from_spine(self, spine_items, remove_if_no_longer_in_spine=True): + nixed = set() + for (name, remove), (item, xname, linear) in zip(spine_items, self.spine_iter): + if remove and name == xname: + self.remove_from_xml(item) + nixed.add(name) + if remove_if_no_longer_in_spine: + # Remove from the book if no longer in spine + nixed -= {name for name, linear in self.spine_names} + for name in nixed: + self.remove_item(name) + def remove_item(self, name): ''' Remove the item identified by name from this container. This removes all @@ -623,6 +648,34 @@ class EpubContainer(Container): ans['container'] = copy.deepcopy(self.container) return ans + @property + def names_that_need_not_be_manifested(self): + return super(EpubContainer, self).names_that_need_not_be_manifested | {'META-INF/' + x for x in self.META_INF} + + @property + def names_that_must_not_be_removed(self): + return super(EpubContainer, self).names_that_must_not_be_removed | {'META-INF/container.xml'} + + def remove_item(self, name): + # Handle removal of obfuscated fonts + if name == 'META-INF/encryption.xml': + self.obfuscated_fonts.clear() + if name in self.obfuscated_fonts: + self.obfuscated_fonts.pop(name, None) + enc = self.parsed('META-INF/encryption.xml') + for em in enc.xpath('//*[local-name()="EncryptionMethod" and @Algorithm]'): + alg = em.get('Algorithm') + if alg not in {ADOBE_OBFUSCATION, IDPF_OBFUSCATION}: + continue + try: + cr = em.getparent().xpath('descendant::*[local-name()="CipherReference" and @URI]')[0] + except (IndexError, ValueError, KeyError): + continue + if name == self.href_to_name(cr.get('URI')): + self.remove_from_xml(em.getparent()) + self.dirty('META-INF/encryption.xml') + super(EpubContainer, self).remove_item(name) + def process_encryption(self): fonts = {} enc = self.parsed('META-INF/encryption.xml') @@ -630,7 +683,10 @@ class EpubContainer(Container): alg = em.get('Algorithm') if alg not in {ADOBE_OBFUSCATION, IDPF_OBFUSCATION}: raise DRMError() - cr = em.getparent().xpath('descendant::*[local-name()="CipherReference" and @URI]')[0] + try: + cr = em.getparent().xpath('descendant::*[local-name()="CipherReference" and @URI]')[0] + except (IndexError, ValueError, KeyError): + continue name = self.href_to_name(cr.get('URI')) path = self.name_path_map.get(name, None) if path is not None: diff --git a/src/calibre/gui2/tweak_book/file_list.py b/src/calibre/gui2/tweak_book/file_list.py index 554a24173f..9c210f8872 100644 --- a/src/calibre/gui2/tweak_book/file_list.py +++ b/src/calibre/gui2/tweak_book/file_list.py @@ -8,15 +8,18 @@ __copyright__ = '2013, Kovid Goyal ' from PyQt4.Qt import ( QWidget, QTreeWidget, QGridLayout, QSize, Qt, QTreeWidgetItem, QIcon, - QStyledItemDelegate, QStyle, QPixmap, QPainter) + QStyledItemDelegate, QStyle, QPixmap, QPainter, pyqtSignal) -from calibre import guess_type, human_readable +from calibre import human_readable from calibre.ebooks.oeb.base import OEB_STYLES, OEB_DOCS +from calibre.ebooks.oeb.polish.container import guess_type from calibre.ebooks.oeb.polish.cover import get_cover_page_name, get_raster_cover_name +from calibre.gui2 import error_dialog from calibre.gui2.tweak_book import current_container TOP_ICON_SIZE = 24 NAME_ROLE = Qt.UserRole +CATEGORY_ROLE = NAME_ROLE + 1 NBSP = '\xa0' class ItemDelegate(QStyledItemDelegate): # {{{ @@ -50,6 +53,8 @@ class ItemDelegate(QStyledItemDelegate): # {{{ class FileList(QTreeWidget): + delete_requested = pyqtSignal(object, object) + def __init__(self, parent=None): QTreeWidget.__init__(self, parent) self.delegate = ItemDelegate(self) @@ -116,7 +121,7 @@ class FileList(QTreeWidget): for names in container.manifest_type_map.itervalues(): manifested_names |= set(names) - font_types = {guess_type('a.'+x)[0] for x in ('ttf', 'otf', 'woff')} + font_types = {guess_type('a.'+x) for x in ('ttf', 'otf', 'woff')} def get_category(mt): category = 'misc' @@ -171,8 +176,10 @@ class FileList(QTreeWidget): icon = self.rendered_emblem_cache[emblems] = canvas item.setData(0, Qt.DecorationRole, icon) + ok_to_be_unmanifested = container.names_that_need_not_be_manifested + def create_item(name, linear=None): - imt = container.mime_map.get(name, guess_type(name)[0]) + imt = container.mime_map.get(name, guess_type(name)) icat = get_category(imt) category = 'text' if linear is not None else ({'text':'misc'}.get(icat, icat)) item = QTreeWidgetItem(self.categories['text' if linear is not None else category], 1) @@ -182,12 +189,13 @@ class FileList(QTreeWidget): item.setFlags(flags) item.setStatusTip(0, _('Full path: ') + name) item.setData(0, NAME_ROLE, name) + item.setData(0, CATEGORY_ROLE, category) set_display_name(name, item) # TODO: Add appropriate tooltips based on the emblems emblems = [] if name in {cover_page_name, cover_image_name}: emblems.append('default_cover.png') - if name not in manifested_names and name not in {container.opf_name, 'META-INF/container.xml', 'META-INF/encryption.xml'}: + if name not in manifested_names and name not in ok_to_be_unmanifested: emblems.append('dialog_question.png') if linear is False: emblems.append('arrow-down.png') @@ -205,7 +213,7 @@ class FileList(QTreeWidget): processed[name] = create_item(name, linear=linear) all_files = list(container.manifest_type_map.iteritems()) - all_files.append((guess_type('a.opf')[0], [container.opf_name])) + all_files.append((guess_type('a.opf'), [container.opf_name])) for name in container.name_path_map: if name in processed: @@ -218,14 +226,54 @@ class FileList(QTreeWidget): def show_context_menu(self, point): pass + def keyPressEvent(self, ev): + if ev.key() in (Qt.Key_Delete, Qt.Key_Backspace): + ev.accept() + self.request_delete() + else: + return QTreeWidget.keyPressEvent(self, ev) + + def request_delete(self): + names = {unicode(item.data(0, NAME_ROLE).toString()) for item in self.selectedItems()} + bad = names & current_container().names_that_must_not_be_removed + if bad: + return error_dialog(self, _('Cannot delete'), + _('The file(s) %s cannot be deleted.') % ('%s' % ', '.join(bad)), show=True) + + text = self.categories['text'] + children = (text.child(i) for i in xrange(text.childCount())) + spine_removals = [(unicode(item.data(0, NAME_ROLE).toString()), item.isSelected()) for item in children] + other_removals = {unicode(item.data(0, NAME_ROLE).toString()) for item in self.selectedItems() + if unicode(item.data(0, CATEGORY_ROLE).toString()) != 'text'} + self.delete_requested.emit(spine_removals, other_removals) + + def delete_done(self, spine_removals, other_removals): + removals = [] + for i, (name, remove) in enumerate(spine_removals): + if remove: + removals.append(self.categories['text'].child(i)) + for category, parent in self.categories.iteritems(): + if category != 'text': + for i in xrange(parent.childCount()): + child = parent.child(i) + if unicode(child.data(0, NAME_ROLE).toString()) in other_removals: + removals.append(child) + + for c in removals: + c.parent().removeChild(c.parent().indexOfChild(c)) + class FileListWidget(QWidget): + delete_requested = pyqtSignal(object, object) + def __init__(self, parent=None): QWidget.__init__(self, parent) self.setLayout(QGridLayout(self)) self.file_list = FileList(self) self.layout().addWidget(self.file_list) self.layout().setContentsMargins(0, 0, 0, 0) + for x in ('delete_requested',): + getattr(self.file_list, x).connect(getattr(self, x)) def build(self, container): self.file_list.build(container)