diff --git a/nbconvert/exporters/__init__.py b/nbconvert/exporters/__init__.py index 98bda706a..c25d4e7fc 100644 --- a/nbconvert/exporters/__init__.py +++ b/nbconvert/exporters/__init__.py @@ -10,6 +10,7 @@ from .pdf import PDFExporter from .webpdf import WebPDFExporter from .qtpdf import QtPDFExporter +from .qtpng import QtPNGExporter from .python import PythonExporter from .rst import RSTExporter from .exporter import Exporter, FilenameExtension diff --git a/nbconvert/exporters/qt_exporter.py b/nbconvert/exporters/qt_exporter.py new file mode 100644 index 000000000..99492054e --- /dev/null +++ b/nbconvert/exporters/qt_exporter.py @@ -0,0 +1,76 @@ +import tempfile, os + +from jupyter_core.paths import jupyter_path +from traitlets import default + +from .html import HTMLExporter + + +class QtExporter(HTMLExporter): + + paginate = None + + def __init__(self, *args, **kwargs): + self.format = self.output_mimetype.split("/")[-1] + super().__init__(*args, **kwargs) + + @default('file_extension') + def _file_extension_default(self): + return "." + self.format + + @default('template_name') + def _template_name_default(self): + return "qt" + self.format + + @default('template_data_paths') + def _template_data_paths_default(self): + return jupyter_path("nbconvert", "templates", "qt" + self.format) + + def _check_launch_reqs(self): + try: + from PyQt5.QtWidgets import QApplication + from .qt_screenshot import QtScreenshot + except ModuleNotFoundError as e: + raise RuntimeError(f"PyQtWebEngine is not installed to support Qt {self.format.upper()} conversion. " + f"Please install `nbconvert[qt{self.format}]` to enable.") from e + return QApplication, QtScreenshot + + def run_pyqtwebengine(self, html): + """Run pyqtwebengine.""" + + ext = ".html" + temp_file = tempfile.NamedTemporaryFile(suffix=ext, delete=False) + filename = f"{temp_file.name[:-len(ext)]}.{self.format}" + with temp_file: + temp_file.write(html.encode('utf-8')) + + try: + QApplication, QtScreenshot = self._check_launch_reqs() + app = QApplication([""]) + s = QtScreenshot(app) + s.capture(f"file://{temp_file.name}", filename, self.paginate) + finally: + # Ensure the file is deleted even if pyqtwebengine raises an exception + os.unlink(temp_file.name) + data = b"" + if os.path.exists(filename): + with open(filename, "rb") as f: + data = f.read() + os.unlink(filename) + return data + + def from_notebook_node(self, nb, resources=None, **kw): + self._check_launch_reqs() + html, resources = super().from_notebook_node( + nb, resources=resources, **kw + ) + + self.log.info(f"Building {self.format.upper()}") + data = self.run_pyqtwebengine(html) + self.log.info(f"{self.format.upper()} successfully created") + + # convert output extension + # the writer above required it to be html + resources['output_extension'] = f".{self.format}" + + return data, resources diff --git a/nbconvert/exporters/qt_screenshot.py b/nbconvert/exporters/qt_screenshot.py new file mode 100644 index 000000000..2b3f14cb0 --- /dev/null +++ b/nbconvert/exporters/qt_screenshot.py @@ -0,0 +1,51 @@ +from PyQt5.QtWidgets import QApplication +from PyQt5 import QtCore +from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineSettings +from PyQt5.QtGui import QPageLayout, QPageSize + + +class QtScreenshot(QWebEngineView): + + def __init__(self, app): + super().__init__() + self.app = app + + def capture(self, url, output_file, paginate): + self.output_file = output_file + self.paginate = paginate + self.load(QtCore.QUrl(url)) + self.loadFinished.connect(self.on_loaded) + # Create hidden view without scrollbars + self.setAttribute(QtCore.Qt.WA_DontShowOnScreen) + self.page().settings().setAttribute( + QWebEngineSettings.ShowScrollBars, False) + if output_file.endswith(".pdf"): + self.export = self.export_pdf + self.page().pdfPrintingFinished.connect(lambda *args: self.app.exit()) + elif output_file.endswith(".png"): + self.export = self.export_png + else: + raise RuntimeError(f"Export file extension not supported: {output_file}") + self.show() + self.app.exec() + + def on_loaded(self): + self.size = self.page().contentsSize().toSize() + self.resize(self.size) + # Wait for resize + QtCore.QTimer.singleShot(1000, self.export) + + def export_pdf(self): + if self.paginate: + page_size = QPageSize(QPageSize.A4) + page_layout = QPageLayout(page_size, QPageLayout.Portrait, QtCore.QMarginsF()) + else: + factor = 0.75 + page_size = QPageSize(QtCore.QSizeF(self.size.width() * factor, self.size.height() * factor), QPageSize.Point) + page_layout = QPageLayout(page_size, QPageLayout.Portrait, QtCore.QMarginsF()) + + self.page().printToPdf(self.output_file, pageLayout=page_layout) + + def export_png(self): + self.grab().save(self.output_file, b"PNG") + self.app.quit() diff --git a/nbconvert/exporters/qtpdf.py b/nbconvert/exporters/qtpdf.py index 9087c4282..6a974cf89 100644 --- a/nbconvert/exporters/qtpdf.py +++ b/nbconvert/exporters/qtpdf.py @@ -3,15 +3,12 @@ # Copyright (c) IPython Development Team. # Distributed under the terms of the Modified BSD License. -import tempfile, os +from traitlets import Bool -from traitlets import Bool, default -from jupyter_core.paths import jupyter_path +from .qt_exporter import QtExporter -from .html import HTMLExporter - -class QtPDFExporter(HTMLExporter): +class QtPDFExporter(QtExporter): """Writer designed to write to PDF files. This inherits from :class:`HTMLExporter`. It creates the HTML using the @@ -31,100 +28,3 @@ class QtPDFExporter(HTMLExporter): ).tag(config=True) output_mimetype = "application/pdf" - - @default('file_extension') - def _file_extension_default(self): - return '.pdf' - - @default('template_name') - def _template_name_default(self): - return 'qtpdf' - - @default('template_data_paths') - def _template_data_paths_default(self): - return jupyter_path("nbconvert", "templates", "qtpdf") - - def _check_launch_reqs(self): - try: - from PyQt5.QtWidgets import QApplication - from PyQt5 import QtCore - from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineSettings - from PyQt5.QtGui import QPageLayout, QPageSize - except ModuleNotFoundError as e: - raise RuntimeError("PyQtWebEngine is not installed to support Qt PDF conversion. " - "Please install `nbconvert[qtpdf]` to enable.") from e - return QApplication, QtCore, QWebEngineView, QWebEngineSettings, QPageLayout, QPageSize - - def run_pyqtwebengine(self, html): - """Run pyqtwebengine.""" - - ext = ".html" - temp_file = tempfile.NamedTemporaryFile(suffix=ext, delete=False) - pdf_filename = f"{temp_file.name[:-len(ext)]}.pdf" - with temp_file: - temp_file.write(html.encode('utf-8')) - - try: - QApplication, QtCore, QWebEngineView, QWebEngineSettings, QPageLayout, QPageSize = self._check_launch_reqs() - - class Screenshot(QWebEngineView): - def __init__(self, app, paginate): - super().__init__() - self.app = app - self.paginate = paginate - def capture(self, url, output_file): - self.output_file = output_file - self.load(QtCore.QUrl(url)) - self.loadFinished.connect(self.on_loaded) - # Create hidden view without scrollbars - self.setAttribute(QtCore.Qt.WA_DontShowOnScreen) - self.page().settings().setAttribute( - QWebEngineSettings.ShowScrollBars, False) - self.page().pdfPrintingFinished.connect( - lambda *args: self.app.exit()) - self.show() - self.app.exec() - def on_loaded(self): - self.size = self.page().contentsSize().toSize() - self.resize(self.size) - # Wait for resize - QtCore.QTimer.singleShot(1000, self.take_screenshot) - def take_screenshot(self): - if self.paginate: - page_size = QPageSize(QPageSize.A4) - page_layout = QPageLayout(page_size, QPageLayout.Portrait, QtCore.QMarginsF()) - else: - factor = 0.75 - page_size = QPageSize(QtCore.QSizeF(self.size.width() * factor, self.size.height() * factor), QPageSize.Point) - page_layout = QPageLayout(page_size, QPageLayout.Portrait, QtCore.QMarginsF()) - - self.page().printToPdf(pdf_filename, pageLayout=page_layout) - - app = QApplication([""]) - s = Screenshot(app, self.paginate) - s.capture(f"file://{temp_file.name}", pdf_filename) - finally: - # Ensure the file is deleted even if pyqtwebengine raises an exception - os.unlink(temp_file.name) - pdf_data = b"" - if os.path.exists(pdf_filename): - with open(pdf_filename, "rb") as f: - pdf_data = f.read() - os.unlink(pdf_filename) - return pdf_data - - def from_notebook_node(self, nb, resources=None, **kw): - self._check_launch_reqs() - html, resources = super().from_notebook_node( - nb, resources=resources, **kw - ) - - self.log.info('Building PDF') - pdf_data = self.run_pyqtwebengine(html) - self.log.info('PDF successfully created') - - # convert output extension to pdf - # the writer above required it to be html - resources['output_extension'] = '.pdf' - - return pdf_data, resources diff --git a/nbconvert/exporters/qtpng.py b/nbconvert/exporters/qtpng.py new file mode 100644 index 000000000..45e99cfd1 --- /dev/null +++ b/nbconvert/exporters/qtpng.py @@ -0,0 +1,17 @@ +"""Export to PNG via a headless browser""" + +# Copyright (c) IPython Development Team. +# Distributed under the terms of the Modified BSD License. + +from .qt_exporter import QtExporter + + +class QtPNGExporter(QtExporter): + """Writer designed to write to PNG files. + + This inherits from :class:`HTMLExporter`. It creates the HTML using the + template machinery, and then uses pyqtwebengine to create a png. + """ + export_from_notebook = "PNG via HTML" + + output_mimetype = "image/png" diff --git a/nbconvert/exporters/tests/test_qtpdf.py b/nbconvert/exporters/tests/test_qtpdf.py index 633b0ab37..73c054490 100644 --- a/nbconvert/exporters/tests/test_qtpdf.py +++ b/nbconvert/exporters/tests/test_qtpdf.py @@ -1,13 +1,8 @@ -"""Tests for the latex preprocessor""" +"""Tests for the qtpdf preprocessor""" # Copyright (c) IPython Development Team. # Distributed under the terms of the Modified BSD License. -import io -import pytest - -from unittest.mock import patch - from .base import ExportersTestsBase from ..qtpdf import QtPDFExporter @@ -16,7 +11,6 @@ class TestQtPDFExporter(ExportersTestsBase): exporter_class = QtPDFExporter - @pytest.mark.network def test_export(self): """ Can a TemplateExporter export something? diff --git a/nbconvert/exporters/tests/test_qtpng.py b/nbconvert/exporters/tests/test_qtpng.py new file mode 100644 index 000000000..2f515aa0c --- /dev/null +++ b/nbconvert/exporters/tests/test_qtpng.py @@ -0,0 +1,19 @@ +"""Tests for the qtpng preprocessor""" + +# Copyright (c) IPython Development Team. +# Distributed under the terms of the Modified BSD License. + +from .base import ExportersTestsBase +from ..qtpng import QtPNGExporter + +class TestQtPNGExporter(ExportersTestsBase): + """Contains test functions for qtpng.py""" + + exporter_class = QtPNGExporter + + def test_export(self): + """ + Can a TemplateExporter export something? + """ + (output, resources) = QtPNGExporter().from_filename(self._get_notebook()) + assert len(output) > 0 diff --git a/setup.py b/setup.py index 429f0d8a0..6b7f2c15c 100644 --- a/setup.py +++ b/setup.py @@ -247,6 +247,9 @@ def get_data_files(): 'qtpdf': [ 'pyqtwebengine>=5.15' ], + 'qtpng': [ + 'pyqtwebengine>=5.15' + ], 'docs': [ 'sphinx>=1.5.1', 'sphinx_rtd_theme', @@ -271,6 +274,7 @@ def get_data_files(): 'pdf=nbconvert.exporters:PDFExporter', 'webpdf=nbconvert.exporters:WebPDFExporter', 'qtpdf=nbconvert.exporters:QtPDFExporter', + 'qtpng=nbconvert.exporters:QtPNGExporter', 'markdown=nbconvert.exporters:MarkdownExporter', 'python=nbconvert.exporters:PythonExporter', 'rst=nbconvert.exporters:RSTExporter', diff --git a/share/jupyter/nbconvert/templates/qtpng/conf.json b/share/jupyter/nbconvert/templates/qtpng/conf.json new file mode 100644 index 000000000..e0bfd6058 --- /dev/null +++ b/share/jupyter/nbconvert/templates/qtpng/conf.json @@ -0,0 +1,6 @@ +{ + "base_template": "lab", + "mimetypes": { + "image/png": true + } +} diff --git a/share/jupyter/nbconvert/templates/qtpng/index.png.j2 b/share/jupyter/nbconvert/templates/qtpng/index.png.j2 new file mode 100644 index 000000000..047c0dcce --- /dev/null +++ b/share/jupyter/nbconvert/templates/qtpng/index.png.j2 @@ -0,0 +1 @@ +{%- extends 'lab/index.html.j2' -%}