1#!/usr/local/bin/python3.8 2# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai 3 4 5__license__ = 'GPL v3' 6__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>' 7__docformat__ = 'restructuredtext en' 8 9import os, weakref, shutil 10 11from qt.core import (QDialog, QVBoxLayout, QHBoxLayout, QRadioButton, QFrame, 12 QPushButton, QLabel, QGroupBox, QGridLayout, QIcon, QSize, QTimer) 13 14from calibre import as_unicode 15from calibre.constants import ismacos 16from calibre.gui2 import error_dialog, question_dialog, open_local_file, gprefs 17from calibre.gui2.actions import InterfaceAction 18from calibre.ptempfile import (PersistentTemporaryDirectory, 19 PersistentTemporaryFile) 20from calibre.utils.config import prefs, tweaks 21 22 23class UnpackBook(QDialog): 24 25 def __init__(self, parent, book_id, fmts, db): 26 QDialog.__init__(self, parent) 27 self.setWindowIcon(QIcon(I('unpack-book.png'))) 28 self.book_id, self.fmts, self.db_ref = book_id, fmts, weakref.ref(db) 29 self._exploded = None 30 self._cleanup_dirs = [] 31 self._cleanup_files = [] 32 33 self.setup_ui() 34 self.setWindowTitle(_('Unpack book') + ' - ' + db.title(book_id, 35 index_is_id=True)) 36 37 button = self.fmt_choice_buttons[0] 38 button_map = {str(x.text()):x for x in self.fmt_choice_buttons} 39 of = prefs['output_format'].upper() 40 df = tweaks.get('default_tweak_format', None) 41 lf = gprefs.get('last_tweak_format', None) 42 if df and df.lower() == 'remember' and lf in button_map: 43 button = button_map[lf] 44 elif df and df.upper() in button_map: 45 button = button_map[df.upper()] 46 elif of in button_map: 47 button = button_map[of] 48 button.setChecked(True) 49 50 self.init_state() 51 for button in self.fmt_choice_buttons: 52 button.toggled.connect(self.init_state) 53 54 def init_state(self, *args): 55 self._exploded = None 56 self.preview_button.setEnabled(False) 57 self.rebuild_button.setEnabled(False) 58 self.explode_button.setEnabled(True) 59 60 def setup_ui(self): # {{{ 61 self._g = g = QHBoxLayout(self) 62 self.setLayout(g) 63 self._l = l = QVBoxLayout() 64 g.addLayout(l) 65 66 fmts = sorted(x.upper() for x in self.fmts) 67 self.fmt_choice_box = QGroupBox(_('Choose the format to unpack:'), self) 68 self._fl = fl = QHBoxLayout() 69 self.fmt_choice_box.setLayout(self._fl) 70 self.fmt_choice_buttons = [QRadioButton(y, self) for y in fmts] 71 for x in self.fmt_choice_buttons: 72 fl.addWidget(x, stretch=10 if x is self.fmt_choice_buttons[-1] else 73 0) 74 l.addWidget(self.fmt_choice_box) 75 self.fmt_choice_box.setVisible(len(fmts) > 1) 76 77 self.help_label = QLabel(_('''\ 78 <h2>About Unpack book</h2> 79 <p>Unpack book allows you to fine tune the appearance of an e-book by 80 making small changes to its internals. In order to use Unpack book, 81 you need to know a little bit about HTML and CSS, technologies that 82 are used in e-books. Follow the steps:</p> 83 <br> 84 <ol> 85 <li>Click "Explode book": This will "explode" the book into its 86 individual internal components.<br></li> 87 <li>Right click on any individual file and select "Open with..." to 88 edit it in your favorite text editor.<br></li> 89 <li>When you are done: <b>close the file browser window 90 and the editor windows you used to make your tweaks</b>. Then click 91 the "Rebuild book" button, to update the book in your calibre 92 library.</li> 93 </ol>''')) 94 self.help_label.setWordWrap(True) 95 self._fr = QFrame() 96 self._fr.setFrameShape(QFrame.Shape.VLine) 97 g.addWidget(self._fr) 98 g.addWidget(self.help_label) 99 100 self._b = b = QGridLayout() 101 left, top, right, bottom = b.getContentsMargins() 102 top += top 103 b.setContentsMargins(left, top, right, bottom) 104 l.addLayout(b, stretch=10) 105 106 self.explode_button = QPushButton(QIcon(I('wizard.png')), _('&Explode book')) 107 self.preview_button = QPushButton(QIcon(I('view.png')), _('&Preview book')) 108 self.cancel_button = QPushButton(QIcon(I('window-close.png')), _('&Cancel')) 109 self.rebuild_button = QPushButton(QIcon(I('exec.png')), _('&Rebuild book')) 110 111 self.explode_button.setToolTip( 112 _('Explode the book to edit its components')) 113 self.preview_button.setToolTip( 114 _('Preview the result of your changes')) 115 self.cancel_button.setToolTip( 116 _('Abort without saving any changes')) 117 self.rebuild_button.setToolTip( 118 _('Save your changes and update the book in the calibre library')) 119 120 a = b.addWidget 121 a(self.explode_button, 0, 0, 1, 1) 122 a(self.preview_button, 0, 1, 1, 1) 123 a(self.cancel_button, 1, 0, 1, 1) 124 a(self.rebuild_button, 1, 1, 1, 1) 125 126 for x in ('explode', 'preview', 'cancel', 'rebuild'): 127 getattr(self, x+'_button').clicked.connect(getattr(self, x)) 128 129 self.msg = QLabel('dummy', self) 130 self.msg.setVisible(False) 131 self.msg.setStyleSheet(''' 132 QLabel { 133 text-align: center; 134 background-color: white; 135 color: black; 136 border-width: 1px; 137 border-style: solid; 138 border-radius: 20px; 139 font-size: x-large; 140 font-weight: bold; 141 } 142 ''') 143 144 self.resize(self.sizeHint() + QSize(40, 10)) 145 # }}} 146 147 def show_msg(self, msg): 148 self.msg.setText(msg) 149 self.msg.resize(self.size() - QSize(50, 25)) 150 self.msg.move((self.width() - self.msg.width())//2, 151 (self.height() - self.msg.height())//2) 152 self.msg.setVisible(True) 153 154 def hide_msg(self): 155 self.msg.setVisible(False) 156 157 def explode(self): 158 self.show_msg(_('Exploding, please wait...')) 159 if len(self.fmt_choice_buttons) > 1: 160 gprefs.set('last_tweak_format', self.current_format.upper()) 161 QTimer.singleShot(5, self.do_explode) 162 163 def ask_question(self, msg): 164 return question_dialog(self, _('Are you sure?'), msg) 165 166 def do_explode(self): 167 from calibre.ebooks.tweak import get_tools, Error, WorkerError 168 tdir = PersistentTemporaryDirectory('_tweak_explode') 169 self._cleanup_dirs.append(tdir) 170 det_msg = None 171 try: 172 src = self.db.format(self.book_id, self.current_format, 173 index_is_id=True, as_path=True) 174 self._cleanup_files.append(src) 175 exploder = get_tools(self.current_format)[0] 176 opf = exploder(src, tdir, question=self.ask_question) 177 except WorkerError as e: 178 det_msg = e.orig_tb 179 except Error as e: 180 return error_dialog(self, _('Failed to unpack'), 181 (_('Could not explode the %s file.')%self.current_format) + ' ' + as_unicode(e), show=True) 182 except: 183 import traceback 184 det_msg = traceback.format_exc() 185 finally: 186 self.hide_msg() 187 188 if det_msg is not None: 189 return error_dialog(self, _('Failed to unpack'), 190 _('Could not explode the %s file. Click "Show details" for ' 191 'more information.')%self.current_format, det_msg=det_msg, 192 show=True) 193 194 if opf is None: 195 # The question was answered with No 196 return 197 198 self._exploded = tdir 199 self.explode_button.setEnabled(False) 200 self.preview_button.setEnabled(True) 201 self.rebuild_button.setEnabled(True) 202 open_local_file(tdir) 203 204 def rebuild_it(self): 205 from calibre.ebooks.tweak import get_tools, WorkerError 206 src_dir = self._exploded 207 det_msg = None 208 of = PersistentTemporaryFile('_tweak_rebuild.'+self.current_format.lower()) 209 of.close() 210 of = of.name 211 self._cleanup_files.append(of) 212 try: 213 rebuilder = get_tools(self.current_format)[1] 214 rebuilder(src_dir, of) 215 except WorkerError as e: 216 det_msg = e.orig_tb 217 except: 218 import traceback 219 det_msg = traceback.format_exc() 220 finally: 221 self.hide_msg() 222 223 if det_msg is not None: 224 error_dialog(self, _('Failed to rebuild file'), 225 _('Failed to rebuild %s. For more information, click ' 226 '"Show details".')%self.current_format, 227 det_msg=det_msg, show=True) 228 return None 229 230 return of 231 232 def preview(self): 233 self.show_msg(_('Rebuilding, please wait...')) 234 QTimer.singleShot(5, self.do_preview) 235 236 def do_preview(self): 237 rebuilt = self.rebuild_it() 238 if rebuilt is not None: 239 self.parent().iactions['View']._view_file(rebuilt) 240 241 def rebuild(self): 242 self.show_msg(_('Rebuilding, please wait...')) 243 QTimer.singleShot(5, self.do_rebuild) 244 245 def do_rebuild(self): 246 rebuilt = self.rebuild_it() 247 if rebuilt is not None: 248 fmt = os.path.splitext(rebuilt)[1][1:].upper() 249 with open(rebuilt, 'rb') as f: 250 self.db.add_format(self.book_id, fmt, f, index_is_id=True) 251 self.accept() 252 253 def cancel(self): 254 self.reject() 255 256 def cleanup(self): 257 if ismacos and self._exploded: 258 try: 259 import appscript 260 self.finder = appscript.app('Finder') 261 self.finder.Finder_windows[os.path.basename(self._exploded)].close() 262 except: 263 pass 264 265 for f in self._cleanup_files: 266 try: 267 os.remove(f) 268 except: 269 pass 270 271 for d in self._cleanup_dirs: 272 try: 273 shutil.rmtree(d) 274 except: 275 pass 276 277 @property 278 def db(self): 279 return self.db_ref() 280 281 @property 282 def current_format(self): 283 for b in self.fmt_choice_buttons: 284 if b.isChecked(): 285 return str(b.text()) 286 287 288class UnpackBookAction(InterfaceAction): 289 290 name = 'Unpack Book' 291 action_spec = (_('Unpack book'), 'unpack-book.png', 292 _('Unpack books in the EPUB, AZW3, HTMLZ formats into their individual components'), 'U') 293 dont_add_to = frozenset(['context-menu-device']) 294 action_type = 'current' 295 296 accepts_drops = True 297 298 def accept_enter_event(self, event, mime_data): 299 if mime_data.hasFormat("application/calibre+from_library"): 300 return True 301 return False 302 303 def accept_drag_move_event(self, event, mime_data): 304 if mime_data.hasFormat("application/calibre+from_library"): 305 return True 306 return False 307 308 def drop_event(self, event, mime_data): 309 mime = 'application/calibre+from_library' 310 if mime_data.hasFormat(mime): 311 self.dropped_ids = tuple(map(int, mime_data.data(mime).data().split())) 312 QTimer.singleShot(1, self.do_drop) 313 return True 314 return False 315 316 def do_drop(self): 317 book_ids = self.dropped_ids 318 del self.dropped_ids 319 if book_ids: 320 self.do_tweak(book_ids[0]) 321 322 def genesis(self): 323 self.qaction.triggered.connect(self.tweak_book) 324 325 def tweak_book(self): 326 row = self.gui.library_view.currentIndex() 327 if not row.isValid(): 328 return error_dialog(self.gui, _('Cannot unpack book'), 329 _('No book selected'), show=True) 330 331 book_id = self.gui.library_view.model().id(row) 332 self.do_tweak(book_id) 333 334 def do_tweak(self, book_id): 335 db = self.gui.library_view.model().db 336 fmts = db.formats(book_id, index_is_id=True) or '' 337 fmts = [x.lower().strip() for x in fmts.split(',')] 338 tweakable_fmts = set(fmts).intersection({'epub', 'htmlz', 'azw3', 339 'mobi', 'azw'}) 340 if not tweakable_fmts: 341 return error_dialog(self.gui, _('Cannot unpack book'), 342 _('The book must be in ePub, HTMLZ or AZW3 formats to unpack.' 343 '\n\nFirst convert the book to one of these formats.'), 344 show=True) 345 dlg = UnpackBook(self.gui, book_id, tweakable_fmts, db) 346 dlg.exec() 347 dlg.cleanup() 348