mupdfwrap_gui.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. #! /usr/bin/env python3
  2. '''
  3. Basic PDF viewer using PyQt and MuPDF's Python bindings.
  4. Hot-keys in main window:
  5. += zooms in
  6. -_ zoom out
  7. 0 reset zoom.
  8. Up/down, page-up/down Scroll current page.
  9. Shift page-up/down Move to next/prev page.
  10. Command-line usage:
  11. -h
  12. --help
  13. Show this help.
  14. <path>
  15. Show specified PDF file.
  16. Example usage:
  17. These examples build+install the MuPDF Python bindings into a Python
  18. virtual environment, which enables this script's 'import mupdf' to work
  19. without having to set PYTHONPATH.
  20. Linux:
  21. > python3 -m venv pylocal
  22. > . pylocal/bin/activate
  23. (pylocal) > pip install libclang pyqt5
  24. (pylocal) > cd .../mupdf
  25. (pylocal) > python setup.py install
  26. (pylocal) > python scripts/mupdfwrap_gui.py
  27. Windows (in a Cmd terminal):
  28. > py -m venv pylocal
  29. > pylocal\Scripts\activate
  30. (pylocal) > pip install libclang pyqt5
  31. (pylocal) > cd ...\mupdf
  32. (pylocal) > python setup.py install
  33. (pylocal) > python scripts\mupdfwrap_gui.py
  34. OpenBSD:
  35. # It seems that pip can't install py1t5 or libclang so instead we
  36. # install system packages and use --system-site-packages.]
  37. > sudo pkg_add py3-llvm py3-qt5
  38. > python3 -m venv --system-site-packages pylocal
  39. > . pylocal/bin/activate
  40. (pylocal) > cd .../mupdf
  41. (pylocal) > python setup.py install
  42. (pylocal) > python scripts/mupdfwrap_gui.py
  43. '''
  44. import os
  45. import sys
  46. import mupdf
  47. import PyQt5
  48. import PyQt5.Qt
  49. import PyQt5.QtCore
  50. import PyQt5.QtWidgets
  51. class MainWindow(PyQt5.QtWidgets.QMainWindow):
  52. def __init__(self):
  53. super().__init__()
  54. # Set up default state. Zooming works by incrementing self.zoom by +/-
  55. # 1 then using magnification = 2**(self.zoom/self.zoom_multiple).
  56. #
  57. self.page_number = None
  58. self.zoom_multiple = 4
  59. self.zoom = 0
  60. # Create Qt widgets.
  61. #
  62. self.central_widget = PyQt5.QtWidgets.QLabel(self)
  63. self.scroll_area = PyQt5.QtWidgets.QScrollArea()
  64. self.scroll_area.setWidget(self.central_widget)
  65. self.scroll_area.setWidgetResizable(True)
  66. self.setCentralWidget(self.scroll_area)
  67. self.central_widget.setToolTip(
  68. '+= zoom in.\n'
  69. '-_ zoom out.\n'
  70. '0 zoom reset.\n'
  71. 'Shift-page-up prev page.\n'
  72. 'Shift-page-down next page.\n'
  73. )
  74. # Create menus.
  75. #
  76. # Need to store menu actions in self, otherwise they appear to get
  77. # destructed and so don't appear in the menu.
  78. #
  79. self.menu_file_open = PyQt5.QtWidgets.QAction('&Open...')
  80. self.menu_file_open.setToolTip('Open a new PDF.')
  81. self.menu_file_open.triggered.connect(self.open_)
  82. self.menu_file_open.setShortcut(PyQt5.QtGui.QKeySequence("Ctrl+O"))
  83. self.menu_file_show_html = PyQt5.QtWidgets.QAction('&Show html')
  84. self.menu_file_show_html.setToolTip('Convert to HTML and show in separate window.')
  85. self.menu_file_show_html.triggered.connect(self.show_html)
  86. self.menu_file_quit = PyQt5.QtWidgets.QAction('&Quit')
  87. self.menu_file_quit.setToolTip('Exit the application.')
  88. self.menu_file_quit.triggered.connect(self.quit)
  89. self.menu_file_quit.setShortcut(PyQt5.QtGui.QKeySequence("Ctrl+Q"))
  90. menu_file = self.menuBar().addMenu('&File')
  91. menu_file.setToolTipsVisible(True)
  92. menu_file.addAction(self.menu_file_open)
  93. menu_file.addAction(self.menu_file_show_html)
  94. menu_file.addAction(self.menu_file_quit)
  95. def keyPressEvent(self, event):
  96. if self.page_number is None:
  97. #print(f'self.page_number is None')
  98. return
  99. #print(f'event.key()={event.key()}')
  100. # Qt Seems to intercept up/down and page-up/down itself.
  101. modifiers = PyQt5.QtWidgets.QApplication.keyboardModifiers()
  102. #print(f'modifiers={modifiers}')
  103. shift = (modifiers == PyQt5.QtCore.Qt.ShiftModifier)
  104. if 0:
  105. pass
  106. elif shift and event.key() == PyQt5.Qt.Qt.Key_PageUp:
  107. self.goto_page(page_number=self.page_number - 1)
  108. elif shift and event.key() == PyQt5.Qt.Qt.Key_PageDown:
  109. self.goto_page(page_number=self.page_number + 1)
  110. elif event.key() in (ord('='), ord('+')):
  111. self.goto_page(zoom=self.zoom + 1)
  112. elif event.key() in (ord('-'), ord('_')):
  113. self.goto_page(zoom=self.zoom - 1)
  114. elif event.key() == (ord('0')):
  115. self.goto_page(zoom=0)
  116. def resizeEvent(self, event):
  117. self.goto_page(self.page_number, self.zoom)
  118. def show_html(self):
  119. '''
  120. Convert to HTML using Extract, and show in new window using
  121. PyQt5.QtWebKitWidgets.QWebView.
  122. '''
  123. buffer_ = self.page.fz_new_buffer_from_page_with_format(
  124. format="docx",
  125. options="html",
  126. transform=mupdf.FzMatrix(1, 0, 0, 1, 0, 0),
  127. cookie=mupdf.FzCookie(),
  128. )
  129. html_content = buffer_.fz_buffer_extract().decode('utf8')
  130. # Show in a new window using Qt's QWebView.
  131. self.webview = PyQt5.QtWebKitWidgets.QWebView()
  132. self.webview.setHtml(html_content)
  133. self.webview.show()
  134. def open_(self):
  135. '''
  136. Opens new PDF file, using Qt file-chooser dialogue.
  137. '''
  138. path, _ = PyQt5.QtWidgets.QFileDialog.getOpenFileName(self, 'Open', filter='*.pdf')
  139. if path:
  140. self.open_path(path)
  141. def open_path(self, path):
  142. path = os.path.abspath(path)
  143. try:
  144. self.document = mupdf.FzDocument(path)
  145. except Exception as e:
  146. print(f'Failed to open path={path!r}: {e}')
  147. return
  148. self.setWindowTitle(path)
  149. self.goto_page(page_number=0, zoom=0)
  150. def quit(self):
  151. # fixme: should probably use qt to exit?
  152. sys.exit()
  153. def goto_page(self, page_number=None, zoom=None):
  154. '''
  155. Updates display to show specified page number and zoom level,
  156. defaulting to current values if None.
  157. Updates self.page_number and self.zoom if we are successful.
  158. '''
  159. # Recreate the bitmap that we are displaying. We should probably use a
  160. # mupdf.FzDisplayList to avoid processing the page each time we need to
  161. # change zoom etc.
  162. #
  163. # We can run out of memory for large zoom values; should probably only
  164. # create bitmap for the visible region (or maybe slightly larger than
  165. # the visible region to allow for some limited scrolling?).
  166. #
  167. if page_number is None:
  168. page_number = self.page_number
  169. if zoom is None:
  170. zoom = self.zoom
  171. if page_number is None or page_number < 0 or page_number >= self.document.fz_count_pages():
  172. return
  173. self.page = mupdf.FzPage(self.document, page_number)
  174. page_rect = self.page.fz_bound_page()
  175. z = 2**(zoom / self.zoom_multiple)
  176. # For now we always use 'fit width' view semantics.
  177. #
  178. # Using -2 here avoids always-present horizontal scrollbar; not sure
  179. # why...
  180. z *= (self.centralWidget().size().width() - 2) / (page_rect.x1 - page_rect.x0)
  181. # Need to preserve the pixmap after we return because the Qt image will
  182. # refer to it, so we use self.pixmap.
  183. try:
  184. self.pixmap = self.page.fz_new_pixmap_from_page_contents(
  185. ctm=mupdf.FzMatrix(z, 0, 0, z, 0, 0),
  186. cs=mupdf.FzColorspace(mupdf.FzColorspace.Fixed_RGB),
  187. alpha=0,
  188. )
  189. except Exception as e:
  190. print(f'self.page.fz_new_pixmap_from_page_contents() failed: {e}')
  191. return
  192. image = PyQt5.QtGui.QImage(
  193. int(self.pixmap.fz_pixmap_samples()),
  194. self.pixmap.fz_pixmap_width(),
  195. self.pixmap.fz_pixmap_height(),
  196. self.pixmap.fz_pixmap_stride(),
  197. PyQt5.QtGui.QImage.Format_RGB888,
  198. );
  199. qpixmap = PyQt5.QtGui.QPixmap.fromImage(image)
  200. self.central_widget.setPixmap(qpixmap)
  201. self.page_number = page_number
  202. self.zoom = zoom
  203. def main():
  204. app = PyQt5.QtWidgets.QApplication([])
  205. main_window = MainWindow()
  206. args = iter(sys.argv[1:])
  207. while 1:
  208. try:
  209. arg = next(args)
  210. except StopIteration:
  211. break
  212. if arg.startswith('-'):
  213. if arg in ('-h', '--help'):
  214. print(__doc__)
  215. return
  216. elif arg == '--html':
  217. main_window.show_html()
  218. else:
  219. raise Exception(f'Unrecognised option {arg!r}')
  220. else:
  221. main_window.open_path(arg)
  222. main_window.show()
  223. app.exec_()
  224. if __name__ == '__main__':
  225. main()