Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add qtpdf and qtpng exporters #1611

Merged
merged 19 commits into from
Jul 30, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 48 additions & 19 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,55 +22,84 @@ jobs:
- os: "macos-latest"
python-version: "3.9"
- os: "ubuntu-latest"
python-version: "pypy-3.8"
python-version: "3.10"
fail-fast: false
steps:
- name: Check out repository code
uses: actions/checkout@v2
- name: Run base setup actions
uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1

- uses: conda-incubator/setup-miniconda@v2
with:
mamba-version: "*"
channels: conda-forge
- name: Install conda-forge python
shell: bash -l {0}
run: |
mamba create -n nbconvert
conda activate nbconvert
mamba install python=${{ matrix.python-version }}
- name: Install conda-forge dependencies
shell: bash -l {0}
run: |
mamba create -n nbconvert
conda activate nbconvert
mamba install pip pyqtwebengine pandoc pyxdg
- name: Install Linux dependencies
if: startsWith(runner.os, 'Linux')
run: |
sudo apt-get update
sudo apt-get install texlive-plain-generic inkscape texlive-xetex

# pandoc is not up to date in the ubuntu repos, so we install directly
wget https://github.com/jgm/pandoc/releases/download/2.17.1.1/pandoc-2.17.1.1-1-amd64.deb && sudo dpkg -i pandoc-2.17.1.1-1-amd64.deb

sudo apt-get install xvfb x11-utils libxkbcommon-x11-0
- name: Install package dependencies
shell: bash
shell: bash -l {0}
run: |
pip install codecov --user
pip install -e ".[execute,serve,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
conda activate nbconvert
pip install codecov
pip install -e ".[execute,serve,test]"
which python
python -m ipykernel.kernelspec --sys-prefix

- name: List installed packages
shell: bash -l {0}
run: |
conda activate nbconvert
pip freeze
pip check

- name: Run tests with coverage
if: ${{ !startsWith(matrix.python-version, 'pypy') && !startsWith(runner.os, 'Windows') }}
shell: bash
if: ${{ startsWith(runner.os, 'macos') }}
shell: bash -l {0}
run: |
conda activate nbconvert
# See https://github.com/pyppeteer/pyppeteer/pull/321
pip install -U websockets --user
pip install -U websockets
python -m pytest --cov nbconvert -vv

- name: Run tests on pypy and Windows
if: ${{ startsWith(matrix.python-version, 'pypy') || startsWith(runner.os, 'Windows') }}
shell: bash
if: ${{ startsWith(runner.os, 'linux') }}
shell: bash -l {0}
run: |
conda activate nbconvert
# See https://github.com/pyppeteer/pyppeteer/pull/321
pip install -U websockets --user
pip install -U websockets
#python -m pytest -vv
xvfb-run --auto-servernum `which coverage` run -m pytest -vv

- name: Run tests on pypy and Windows
if: ${{ startsWith(runner.os, 'Windows') }}
shell: bash -l {0}
run: |
conda activate nbconvert
# See https://github.com/pyppeteer/pyppeteer/pull/321
pip install -U websockets
python -m pytest -vv

- name: Code coverage
run: codecov
shell: bash -l {0}
run: |
conda activate nbconvert
codecov

# Run "pre-commit run --all-files --hook-stage=manual"
pre-commit:
Expand Down
2 changes: 2 additions & 0 deletions nbconvert/exporters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions nbconvert/exporters/qt_exporter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import os
import sys
import tempfile

from traitlets import default

from .html import HTMLExporter


class QtExporter(HTMLExporter):

paginate = None

@default("file_extension")
def _file_extension_default(self):
return ".html"

def _check_launch_reqs(self):
if sys.platform.startswith("win") and self.format == "png":
raise RuntimeError("Exporting to PNG using Qt is currently not supported on Windows.")
from .qt_screenshot import QT_INSTALLED

if not QT_INSTALLED:
raise RuntimeError(
f"PyQtWebEngine is not installed to support Qt {self.format.upper()} conversion. "
f"Please install `nbconvert[qt{self.format}]` to enable."
)
from .qt_screenshot import QtScreenshot

return QtScreenshot

def _run_pyqtwebengine(self, html):
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:
QtScreenshot = self._check_launch_reqs()
s = QtScreenshot()
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)
return s.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
77 changes: 77 additions & 0 deletions nbconvert/exporters/qt_screenshot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import os

try:
from PyQt5 import QtCore
from PyQt5.QtGui import QPageLayout, QPageSize
from PyQt5.QtWebEngineWidgets import QWebEngineSettings, QWebEngineView
from PyQt5.QtWidgets import QApplication

QT_INSTALLED = True
except ModuleNotFoundError:
QT_INSTALLED = False


if QT_INSTALLED:
APP = None
if not QApplication.instance():
APP = QApplication([])

class QtScreenshot(QWebEngineView):
def __init__(self):
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)
self.data = b""
if output_file.endswith(".pdf"):
self.export = self.export_pdf

def cleanup(*args):
self.app.quit()
self.get_data()

self.page().pdfPrintingFinished.connect(cleanup)
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, "PNG")
self.app.quit()
self.get_data()

def get_data(self):
if os.path.exists(self.output_file):
with open(self.output_file, "rb") as f:
self.data = f.read()
os.unlink(self.output_file)
30 changes: 30 additions & 0 deletions nbconvert/exporters/qtpdf.py
Original file line number Diff line number Diff line change
@@ -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"
format = "pdf"

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)
17 changes: 17 additions & 0 deletions nbconvert/exporters/qtpng.py
Original file line number Diff line number Diff line change
@@ -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"
format = "png"
24 changes: 24 additions & 0 deletions nbconvert/exporters/tests/test_qtpdf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Tests for the qtpdf preprocessor"""

# Copyright (c) IPython Development Team.
# Distributed under the terms of the Modified BSD License.

import pytest

from ..qt_screenshot import QT_INSTALLED
from ..qtpdf import QtPDFExporter
from .base import ExportersTestsBase


@pytest.mark.skipif(not QT_INSTALLED, reason="PyQtWebEngine not installed")
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
31 changes: 31 additions & 0 deletions nbconvert/exporters/tests/test_qtpng.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Tests for the qtpng preprocessor"""

# Copyright (c) IPython Development Team.
# Distributed under the terms of the Modified BSD License.

import os

import pytest

from ..qt_screenshot import QT_INSTALLED
from ..qtpng import QtPNGExporter
from .base import ExportersTestsBase


@pytest.mark.skipif(not QT_INSTALLED, reason="PyQtWebEngine not installed")
class TestQtPNGExporter(ExportersTestsBase):
"""Contains test functions for qtpng.py"""

exporter_class = QtPNGExporter

def test_export(self):
"""
Can a TemplateExporter export something?
"""
if os.name == "nt":
# currently not supported
with pytest.raises(RuntimeError) as exc_info:
(output, resources) = QtPNGExporter().from_filename(self._get_notebook())
else:
(output, resources) = QtPNGExporter().from_filename(self._get_notebook())
assert len(output) > 0
2 changes: 0 additions & 2 deletions nbconvert/exporters/webpdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@ class WebPDFExporter(HTMLExporter):
""",
).tag(config=True)

output_mimetype = "text/html"

@default("file_extension")
def _file_extension_default(self):
return ".html"
Expand Down
1 change: 1 addition & 0 deletions nbconvert/nbconvertapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading