mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Edit Book: New tool to specify semantics in EPUB books (semantics are items in the guide such as preface, title-page, dedication, etc.). Fixes #1287025 [[ebook-edit] Implement of defining more reference types of guide section](https://bugs.launchpad.net/calibre/+bug/1287025)
This commit is contained in:
parent
c53e46512e
commit
f973436000
@ -20,26 +20,32 @@ def get_book_language(container):
|
|||||||
return code
|
return code
|
||||||
|
|
||||||
def set_guide_item(container, item_type, title, name, frag=None):
|
def set_guide_item(container, item_type, title, name, frag=None):
|
||||||
|
ref_tag = '{%s}reference' % OPF_NAMESPACES['opf']
|
||||||
|
href = None
|
||||||
|
if name:
|
||||||
|
href = container.name_to_href(name, container.opf_name)
|
||||||
|
if frag:
|
||||||
|
href += '#' + frag
|
||||||
|
|
||||||
guides = container.opf_xpath('//opf:guide')
|
guides = container.opf_xpath('//opf:guide')
|
||||||
if not guides:
|
if not guides and href:
|
||||||
g = container.opf.makeelement('{%s}guide' % OPF_NAMESPACES['opf'], nsmap={'opf':OPF_NAMESPACES['opf']})
|
g = container.opf.makeelement('{%s}guide' % OPF_NAMESPACES['opf'], nsmap={'opf':OPF_NAMESPACES['opf']})
|
||||||
container.insert_into_xml(container.opf, g)
|
container.insert_into_xml(container.opf, g)
|
||||||
guides = [g]
|
guides = [g]
|
||||||
ref_tag = '{%s}reference' % OPF_NAMESPACES['opf']
|
|
||||||
href = container.name_to_href(name, container.opf_name)
|
|
||||||
if frag:
|
|
||||||
href += '#' + frag
|
|
||||||
|
|
||||||
for guide in guides:
|
for guide in guides:
|
||||||
matches = []
|
matches = []
|
||||||
for child in guide.iterchildren(etree.Element):
|
for child in guide.iterchildren(etree.Element):
|
||||||
if child.tag == ref_tag and child.get('type', '').lower() == item_type.lower():
|
if child.tag == ref_tag and child.get('type', '').lower() == item_type.lower():
|
||||||
matches.append(child)
|
matches.append(child)
|
||||||
if not matches:
|
if not matches and href:
|
||||||
r = guide.makeelement(ref_tag, type=item_type, nsmap={'opf':OPF_NAMESPACES['opf']})
|
r = guide.makeelement(ref_tag, type=item_type, nsmap={'opf':OPF_NAMESPACES['opf']})
|
||||||
container.insert_into_xml(guide, r)
|
container.insert_into_xml(guide, r)
|
||||||
matches.append(r)
|
matches.append(r)
|
||||||
for m in matches:
|
for m in matches:
|
||||||
m.set('title', title), m.set('href', href), m.set('type', item_type)
|
if href:
|
||||||
|
m.set('title', title), m.set('href', href), m.set('type', item_type)
|
||||||
|
else:
|
||||||
|
container.remove_from_xml(m)
|
||||||
container.dirty(container.opf_name)
|
container.dirty(container.opf_name)
|
||||||
|
|
||||||
|
@ -38,7 +38,7 @@ from calibre.gui2.tweak_book.editor import editor_from_syntax, syntax_from_mime
|
|||||||
from calibre.gui2.tweak_book.editor.insert_resource import get_resource_data, NewBook
|
from calibre.gui2.tweak_book.editor.insert_resource import get_resource_data, NewBook
|
||||||
from calibre.gui2.tweak_book.preferences import Preferences
|
from calibre.gui2.tweak_book.preferences import Preferences
|
||||||
from calibre.gui2.tweak_book.widgets import (
|
from calibre.gui2.tweak_book.widgets import (
|
||||||
RationalizeFolders, MultiSplit, ImportForeign, QuickOpen, InsertLink)
|
RationalizeFolders, MultiSplit, ImportForeign, QuickOpen, InsertLink, InsertSemantics)
|
||||||
|
|
||||||
_diff_dialogs = []
|
_diff_dialogs = []
|
||||||
|
|
||||||
@ -649,6 +649,18 @@ class Boss(QObject):
|
|||||||
else:
|
else:
|
||||||
ed.action_triggered(action)
|
ed.action_triggered(action)
|
||||||
|
|
||||||
|
def set_semantics(self):
|
||||||
|
self.commit_all_editors_to_container()
|
||||||
|
c = current_container()
|
||||||
|
if c.book_type == 'azw3':
|
||||||
|
return error_dialog(self.gui, _('Not supported'), _(
|
||||||
|
'Semantics are not supported for the AZW3 format.'), show=True)
|
||||||
|
d = InsertSemantics(c, parent=self.gui)
|
||||||
|
if d.exec_() == d.Accepted and d.changed_type_map:
|
||||||
|
self.add_savepoint(_('Before: Set Semantics'))
|
||||||
|
d.apply_changes(current_container())
|
||||||
|
self.apply_container_update_to_gui()
|
||||||
|
|
||||||
def show_find(self):
|
def show_find(self):
|
||||||
self.gui.central.show_find()
|
self.gui.central.show_find()
|
||||||
ed = self.gui.central.current_editor
|
ed = self.gui.central.current_editor
|
||||||
|
@ -343,6 +343,8 @@ class Main(MainWindow):
|
|||||||
_('Insert special character'))
|
_('Insert special character'))
|
||||||
self.action_rationalize_folders = reg('mimetypes/dir.png', _('&Arrange into folders'), self.boss.rationalize_folders, 'rationalize-folders', (),
|
self.action_rationalize_folders = reg('mimetypes/dir.png', _('&Arrange into folders'), self.boss.rationalize_folders, 'rationalize-folders', (),
|
||||||
_('Arrange into folders'))
|
_('Arrange into folders'))
|
||||||
|
self.action_set_semantics = reg('tags.png', _('Set &Semantics'), self.boss.set_semantics, 'set-semantics', (),
|
||||||
|
_('Set Semantics'))
|
||||||
|
|
||||||
# Polish actions
|
# Polish actions
|
||||||
group = _('Polish Book')
|
group = _('Polish Book')
|
||||||
@ -472,6 +474,7 @@ class Main(MainWindow):
|
|||||||
e.addAction(self.action_fix_html_all)
|
e.addAction(self.action_fix_html_all)
|
||||||
e.addAction(self.action_pretty_all)
|
e.addAction(self.action_pretty_all)
|
||||||
e.addAction(self.action_rationalize_folders)
|
e.addAction(self.action_rationalize_folders)
|
||||||
|
e.addAction(self.action_set_semantics)
|
||||||
e.addAction(self.action_check_book)
|
e.addAction(self.action_check_book)
|
||||||
|
|
||||||
e = b.addMenu(_('&View'))
|
e = b.addMenu(_('&View'))
|
||||||
|
@ -8,18 +8,19 @@ __copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from itertools import izip
|
from itertools import izip
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
from PyQt4.Qt import (
|
from PyQt4.Qt import (
|
||||||
QDialog, QDialogButtonBox, QGridLayout, QLabel, QLineEdit, QVBoxLayout,
|
QDialog, QDialogButtonBox, QGridLayout, QLabel, QLineEdit, QVBoxLayout,
|
||||||
QFormLayout, QHBoxLayout, QToolButton, QIcon, QApplication, Qt, QWidget,
|
QFormLayout, QHBoxLayout, QToolButton, QIcon, QApplication, Qt, QWidget,
|
||||||
QPoint, QSizePolicy, QPainter, QStaticText, pyqtSignal, QTextOption,
|
QPoint, QSizePolicy, QPainter, QStaticText, pyqtSignal, QTextOption,
|
||||||
QAbstractListModel, QModelIndex, QVariant, QStyledItemDelegate, QStyle,
|
QAbstractListModel, QModelIndex, QVariant, QStyledItemDelegate, QStyle,
|
||||||
QListView, QTextDocument, QSize)
|
QListView, QTextDocument, QSize, QComboBox, QFrame)
|
||||||
|
|
||||||
from calibre import prepare_string_for_xml
|
from calibre import prepare_string_for_xml
|
||||||
from calibre.gui2 import error_dialog, choose_files, choose_save_file, NONE
|
from calibre.gui2 import error_dialog, choose_files, choose_save_file, NONE, info_dialog
|
||||||
from calibre.gui2.tweak_book import tprefs
|
from calibre.gui2.tweak_book import tprefs
|
||||||
from calibre.utils.icu import primary_sort_key
|
from calibre.utils.icu import primary_sort_key, sort_key
|
||||||
from calibre.utils.matcher import get_char, Matcher
|
from calibre.utils.matcher import get_char, Matcher
|
||||||
|
|
||||||
ROOT = QModelIndex()
|
ROOT = QModelIndex()
|
||||||
@ -520,6 +521,11 @@ class NamesModel(QAbstractListModel):
|
|||||||
self.reset()
|
self.reset()
|
||||||
self.filtered.emit(not bool(query))
|
self.filtered.emit(not bool(query))
|
||||||
|
|
||||||
|
def find_name(self, name):
|
||||||
|
for i, (text, positions) in enumerate(self.items):
|
||||||
|
if text == name:
|
||||||
|
return i
|
||||||
|
|
||||||
def create_filterable_names_list(names, filter_text=None, parent=None):
|
def create_filterable_names_list(names, filter_text=None, parent=None):
|
||||||
nl = QListView(parent)
|
nl = QListView(parent)
|
||||||
nl.m = m = NamesModel(names, parent=nl)
|
nl.m = m = NamesModel(names, parent=nl)
|
||||||
@ -642,6 +648,200 @@ class InsertLink(Dialog):
|
|||||||
|
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
|
# Insert Semantics {{{
|
||||||
|
|
||||||
|
class InsertSemantics(Dialog):
|
||||||
|
|
||||||
|
def __init__(self, container, parent=None):
|
||||||
|
self.container = container
|
||||||
|
self.anchor_cache = {}
|
||||||
|
self.original_type_map = {item.get('type', ''):(container.href_to_name(item.get('href'), container.opf_name), item.get('href', '').partition('#')[-1])
|
||||||
|
for item in container.opf_xpath('//opf:guide/opf:reference[@href and @type]')}
|
||||||
|
self.final_type_map = self.original_type_map.copy()
|
||||||
|
self.create_known_type_map()
|
||||||
|
Dialog.__init__(self, _('Set Semantics'), 'insert-semantics', parent=parent)
|
||||||
|
|
||||||
|
def sizeHint(self):
|
||||||
|
return QSize(800, 600)
|
||||||
|
|
||||||
|
def create_known_type_map(self):
|
||||||
|
_ = lambda x: x
|
||||||
|
self.known_type_map = {
|
||||||
|
'title-page': _('Title Page'),
|
||||||
|
'toc': _('Table of Contents'),
|
||||||
|
'index': _('Index'),
|
||||||
|
'glossary': _('Glossary'),
|
||||||
|
'acknowledgements': _('Acknowledgements'),
|
||||||
|
'bibliography': _('Bibliography'),
|
||||||
|
'colophon': _('Colophon'),
|
||||||
|
'copyright-page': _('Copyright page'),
|
||||||
|
'dedication': _('Dedication'),
|
||||||
|
'epigraph': _('Epigraph'),
|
||||||
|
'foreword': _('Foreword'),
|
||||||
|
'loi': _('List of Illustrations'),
|
||||||
|
'lot': _('List of Tables'),
|
||||||
|
'notes:': _('Notes'),
|
||||||
|
'preface': _('Preface'),
|
||||||
|
'text': _('Text'),
|
||||||
|
}
|
||||||
|
_ = __builtins__['_']
|
||||||
|
type_map_help = {
|
||||||
|
'title-page': _('Page with title, author, publisher, etc.'),
|
||||||
|
'index': _('Back-of-book style index'),
|
||||||
|
'text': _('First "real" page of content'),
|
||||||
|
}
|
||||||
|
t = _
|
||||||
|
all_types = [(k, (('%s (%s)' % (t(v), type_map_help[k])) if k in type_map_help else t(v))) for k, v in self.known_type_map.iteritems()]
|
||||||
|
all_types.sort(key=lambda x: sort_key(x[1]))
|
||||||
|
self.all_types = OrderedDict(all_types)
|
||||||
|
|
||||||
|
def setup_ui(self):
|
||||||
|
self.l = l = QVBoxLayout(self)
|
||||||
|
self.setLayout(l)
|
||||||
|
|
||||||
|
self.tl = tl = QFormLayout()
|
||||||
|
self.semantic_type = QComboBox(self)
|
||||||
|
for key, val in self.all_types.iteritems():
|
||||||
|
self.semantic_type.addItem(val, key)
|
||||||
|
tl.addRow(_('Type of &semantics:'), self.semantic_type)
|
||||||
|
self.target = t = QLineEdit(self)
|
||||||
|
t.setPlaceholderText(_('The destination (href) for the link'))
|
||||||
|
tl.addRow(_('&Target:'), t)
|
||||||
|
l.addLayout(tl)
|
||||||
|
|
||||||
|
self.hline = hl = QFrame(self)
|
||||||
|
hl.setFrameStyle(hl.HLine)
|
||||||
|
l.addWidget(hl)
|
||||||
|
|
||||||
|
self.h = h = QHBoxLayout()
|
||||||
|
l.addLayout(h)
|
||||||
|
|
||||||
|
names = [n for n, linear in self.container.spine_names]
|
||||||
|
fn, f = create_filterable_names_list(names, filter_text=_('Filter files'), parent=self)
|
||||||
|
self.file_names, self.file_names_filter = fn, f
|
||||||
|
fn.selectionModel().selectionChanged.connect(self.selected_file_changed)
|
||||||
|
self.fnl = fnl = QVBoxLayout()
|
||||||
|
self.la1 = la = QLabel(_('Choose a &file:'))
|
||||||
|
la.setBuddy(fn)
|
||||||
|
fnl.addWidget(la), fnl.addWidget(f), fnl.addWidget(fn)
|
||||||
|
h.addLayout(fnl), h.setStretch(0, 2)
|
||||||
|
|
||||||
|
fn, f = create_filterable_names_list([], filter_text=_('Filter locations'), parent=self)
|
||||||
|
self.anchor_names, self.anchor_names_filter = fn, f
|
||||||
|
fn.selectionModel().selectionChanged.connect(self.update_target)
|
||||||
|
fn.doubleClicked.connect(self.accept, type=Qt.QueuedConnection)
|
||||||
|
self.anl = fnl = QVBoxLayout()
|
||||||
|
self.la2 = la = QLabel(_('Choose a &location (anchor) in the file:'))
|
||||||
|
la.setBuddy(fn)
|
||||||
|
fnl.addWidget(la), fnl.addWidget(f), fnl.addWidget(fn)
|
||||||
|
h.addLayout(fnl), h.setStretch(1, 1)
|
||||||
|
|
||||||
|
self.bb.addButton(self.bb.Help)
|
||||||
|
self.bb.helpRequested.connect(self.help_requested)
|
||||||
|
l.addWidget(self.bb)
|
||||||
|
self.semantic_type_changed()
|
||||||
|
self.semantic_type.currentIndexChanged.connect(self.semantic_type_changed)
|
||||||
|
self.target.textChanged.connect(self.target_text_changed)
|
||||||
|
|
||||||
|
def help_requested(self):
|
||||||
|
d = info_dialog(self, _('About semantics'), _(
|
||||||
|
'Semantics refer to additional information about specific locations in the book.'
|
||||||
|
' For example, you can specify that a particular location is the dedication or the preface'
|
||||||
|
' or the table of contents and so on.\n\nFirst choose the type of semantic information, then'
|
||||||
|
' choose a file and optionally a location within the file to point to.\n\nThe'
|
||||||
|
' semantic information will be written in the <guide> section of the opf file.'))
|
||||||
|
d.resize(d.sizeHint())
|
||||||
|
d.exec_()
|
||||||
|
|
||||||
|
def semantic_type_changed(self):
|
||||||
|
item_type = unicode(self.semantic_type.itemData(self.semantic_type.currentIndex()).toString())
|
||||||
|
name, frag = self.final_type_map.get(item_type, (None, None))
|
||||||
|
self.show_type(name, frag)
|
||||||
|
|
||||||
|
def show_type(self, name, frag):
|
||||||
|
self.file_names_filter.clear(), self.anchor_names_filter.clear()
|
||||||
|
self.file_names.clearSelection(), self.anchor_names.clearSelection()
|
||||||
|
if name is not None:
|
||||||
|
row = self.file_names.model().find_name(name)
|
||||||
|
if row is not None:
|
||||||
|
sm = self.file_names.selectionModel()
|
||||||
|
sm.select(self.file_names.model().index(row), sm.ClearAndSelect)
|
||||||
|
if frag:
|
||||||
|
row = self.anchor_names.model().find_name(frag)
|
||||||
|
if row is not None:
|
||||||
|
sm = self.anchor_names.selectionModel()
|
||||||
|
sm.select(self.anchor_names.model().index(row), sm.ClearAndSelect)
|
||||||
|
self.target.blockSignals(True)
|
||||||
|
if name is not None:
|
||||||
|
self.target.setText(name + (('#' + frag) if frag else ''))
|
||||||
|
else:
|
||||||
|
self.target.setText('')
|
||||||
|
self.target.blockSignals(False)
|
||||||
|
|
||||||
|
def target_text_changed(self):
|
||||||
|
name, frag = unicode(self.target.text()).partition('#')[::2]
|
||||||
|
item_type = unicode(self.semantic_type.itemData(self.semantic_type.currentIndex()).toString())
|
||||||
|
self.final_type_map[item_type] = (name, frag or None)
|
||||||
|
|
||||||
|
def selected_file_changed(self, *args):
|
||||||
|
rows = list(self.file_names.selectionModel().selectedRows())
|
||||||
|
if not rows:
|
||||||
|
self.anchor_names.model().set_names([])
|
||||||
|
else:
|
||||||
|
name, positions = self.file_names.model().data(rows[0], Qt.UserRole).toPyObject()
|
||||||
|
self.populate_anchors(name)
|
||||||
|
|
||||||
|
def populate_anchors(self, name):
|
||||||
|
if name not in self.anchor_cache:
|
||||||
|
from calibre.ebooks.oeb.base import XHTML_NS
|
||||||
|
root = self.container.parsed(name)
|
||||||
|
self.anchor_cache[name] = sorted(
|
||||||
|
(set(root.xpath('//*/@id')) | set(root.xpath('//h:a/@name', namespaces={'h':XHTML_NS}))) - {''}, key=primary_sort_key)
|
||||||
|
self.anchor_names.model().set_names(self.anchor_cache[name])
|
||||||
|
self.update_target()
|
||||||
|
|
||||||
|
def update_target(self):
|
||||||
|
rows = list(self.file_names.selectionModel().selectedRows())
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
name = self.file_names.model().data(rows[0], Qt.UserRole).toPyObject()[0]
|
||||||
|
href = name
|
||||||
|
frag = ''
|
||||||
|
rows = list(self.anchor_names.selectionModel().selectedRows())
|
||||||
|
if rows:
|
||||||
|
anchor = self.anchor_names.model().data(rows[0], Qt.UserRole).toPyObject()[0]
|
||||||
|
if anchor:
|
||||||
|
frag = '#' + anchor
|
||||||
|
href += frag
|
||||||
|
self.target.setText(href or '#')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def changed_type_map(self):
|
||||||
|
return {k:v for k, v in self.final_type_map.iteritems() if v != self.original_type_map.get(k, None)}
|
||||||
|
|
||||||
|
def apply_changes(self, container):
|
||||||
|
from calibre.ebooks.oeb.polish.opf import set_guide_item, get_book_language
|
||||||
|
from calibre.translations.dynamic import translate
|
||||||
|
lang = get_book_language(container)
|
||||||
|
for item_type, (name, frag) in self.changed_type_map.iteritems():
|
||||||
|
title = self.known_type_map[item_type]
|
||||||
|
if lang:
|
||||||
|
title = translate(lang, title)
|
||||||
|
set_guide_item(container, item_type, title, name, frag=frag)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def test(cls):
|
||||||
|
import sys
|
||||||
|
from calibre.ebooks.oeb.polish.container import get_container
|
||||||
|
c = get_container(sys.argv[-1], tweak_mode=True)
|
||||||
|
d = cls(c)
|
||||||
|
if d.exec_() == d.Accepted:
|
||||||
|
import pprint
|
||||||
|
pprint.pprint(d.changed_type_map)
|
||||||
|
d.apply_changes(d.container)
|
||||||
|
|
||||||
|
# }}}
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
InsertLink.test()
|
InsertSemantics.test()
|
||||||
|
Loading…
x
Reference in New Issue
Block a user