From 313347818c0d4c0a9c5af91f992ada1694afcb00 Mon Sep 17 00:00:00 2001 From: David Brochart Date: Mon, 9 Aug 2021 13:00:58 +0200 Subject: [PATCH] Add qtpdf and qtpng exporters --- .github/workflows/tests.yml | 2 +- nbconvert/exporters/__init__.py | 2 + nbconvert/exporters/qt_exporter.py | 76 +++++++++++++++++++ nbconvert/exporters/qt_screenshot.py | 51 +++++++++++++ nbconvert/exporters/qtpdf.py | 30 ++++++++ nbconvert/exporters/qtpng.py | 17 +++++ nbconvert/exporters/tests/test_qtpdf.py | 19 +++++ nbconvert/exporters/tests/test_qtpng.py | 19 +++++ nbconvert/nbconvertapp.py | 1 + pyproject.toml | 4 + .../nbconvert/templates/qtpdf/conf.json | 6 ++ .../nbconvert/templates/qtpdf/index.pdf.j2 | 1 + .../nbconvert/templates/qtpng/conf.json | 6 ++ .../nbconvert/templates/qtpng/index.png.j2 | 1 + 14 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 nbconvert/exporters/qt_exporter.py create mode 100644 nbconvert/exporters/qt_screenshot.py create mode 100644 nbconvert/exporters/qtpdf.py create mode 100644 nbconvert/exporters/qtpng.py create mode 100644 nbconvert/exporters/tests/test_qtpdf.py create mode 100644 nbconvert/exporters/tests/test_qtpng.py create mode 100644 share/jupyter/nbconvert/templates/qtpdf/conf.json create mode 100644 share/jupyter/nbconvert/templates/qtpdf/index.pdf.j2 create mode 100644 share/jupyter/nbconvert/templates/qtpng/conf.json create mode 100644 share/jupyter/nbconvert/templates/qtpng/index.png.j2 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4b728e400..96d572f1a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,7 +43,7 @@ jobs: shell: bash run: | pip install codecov --user - pip install -e ".[execute,serve,test]" --user + pip install -e ".[execute,serve,qtpdf,qtpng,test]" --user python -m ipykernel.kernelspec --user # many things installed in --users need this to be in $PATH echo "/Users/runner/.local/bin" >> $GITHUB_PATH diff --git a/nbconvert/exporters/__init__.py b/nbconvert/exporters/__init__.py index 8a32b73cc..9aa58d346 100644 --- a/nbconvert/exporters/__init__.py +++ b/nbconvert/exporters/__init__.py @@ -7,6 +7,8 @@ from .notebook import NotebookExporter from .pdf import PDFExporter from .python import PythonExporter +from .qtpdf import QtPDFExporter +from .qtpng import QtPNGExporter from .rst import RSTExporter from .script import ScriptExporter from .slides import SlidesExporter 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 new file mode 100644 index 000000000..6a974cf89 --- /dev/null +++ b/nbconvert/exporters/qtpdf.py @@ -0,0 +1,30 @@ +"""Export to PDF via a headless browser""" + +# Copyright (c) IPython Development Team. +# Distributed under the terms of the Modified BSD License. + +from traitlets import Bool + +from .qt_exporter import QtExporter + + +class QtPDFExporter(QtExporter): + """Writer designed to write to PDF files. + + This inherits from :class:`HTMLExporter`. It creates the HTML using the + template machinery, and then uses pyqtwebengine to create a pdf. + """ + export_from_notebook = "PDF via HTML" + + paginate = Bool( + True, + help=""" + Split generated notebook into multiple pages. + + If False, a PDF with one long page will be generated. + + Set to True to match behavior of LaTeX based PDF generator + """ + ).tag(config=True) + + output_mimetype = "application/pdf" 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 new file mode 100644 index 000000000..73c054490 --- /dev/null +++ b/nbconvert/exporters/tests/test_qtpdf.py @@ -0,0 +1,19 @@ +"""Tests for the qtpdf preprocessor""" + +# Copyright (c) IPython Development Team. +# Distributed under the terms of the Modified BSD License. + +from .base import ExportersTestsBase +from ..qtpdf import QtPDFExporter + +class TestQtPDFExporter(ExportersTestsBase): + """Contains test functions for qtpdf.py""" + + exporter_class = QtPDFExporter + + def test_export(self): + """ + Can a TemplateExporter export something? + """ + (output, resources) = QtPDFExporter().from_filename(self._get_notebook()) + assert len(output) > 0 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/nbconvert/nbconvertapp.py b/nbconvert/nbconvertapp.py index aa3073385..e53de1dd6 100755 --- a/nbconvert/nbconvertapp.py +++ b/nbconvert/nbconvertapp.py @@ -650,6 +650,7 @@ def initialize(self, argv=None): self.config.TemplateExporter.exclude_input_prompt = True self.config.ExecutePreprocessor.enabled = True self.config.WebPDFExporter.paginate = False + self.config.QtPDFExporter.paginate = False super().initialize(argv) diff --git a/pyproject.toml b/pyproject.toml index 773bdced9..f5b092b4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,8 @@ html = "nbconvert.exporters:HTMLExporter" slides = "nbconvert.exporters:SlidesExporter" latex = "nbconvert.exporters:LatexExporter" pdf = "nbconvert.exporters:PDFExporter" +qtpdf = "nbconvert.exporters:QtPDFExporter" +qtpng = "nbconvert.exporters:QtPNGExporter" webpdf = "nbconvert.exporters:WebPDFExporter" markdown = "nbconvert.exporters:MarkdownExporter" python = "nbconvert.exporters:PythonExporter" @@ -64,6 +66,8 @@ test = [ "pyppeteer>=1,<1.1", ] serve = ["tornado>=6.1"] +qtpdf = ["pyqtwebengine>=5.15"] +qtpng = ["pyqtwebengine>=5.15"] webpdf = ["pyppeteer>=1,<1.1"] docs = [ "sphinx>=1.5.1", diff --git a/share/jupyter/nbconvert/templates/qtpdf/conf.json b/share/jupyter/nbconvert/templates/qtpdf/conf.json new file mode 100644 index 000000000..1a56f396b --- /dev/null +++ b/share/jupyter/nbconvert/templates/qtpdf/conf.json @@ -0,0 +1,6 @@ +{ + "base_template": "lab", + "mimetypes": { + "application/pdf": true + } +} diff --git a/share/jupyter/nbconvert/templates/qtpdf/index.pdf.j2 b/share/jupyter/nbconvert/templates/qtpdf/index.pdf.j2 new file mode 100644 index 000000000..047c0dcce --- /dev/null +++ b/share/jupyter/nbconvert/templates/qtpdf/index.pdf.j2 @@ -0,0 +1 @@ +{%- extends 'lab/index.html.j2' -%} 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' -%}