fix: Wonder Ink prints without crashing, and prints worksheets at all
Little Waders, 0.2.2: SIGSEGV in QPrinter::resolution() every time a teacher confirmed a print job. QWebEngineView.print() keeps the raw QPrinter pointer and returns; Qt's didPrintPage() reads printer->resolution() once Chromium has rendered the page, and by then PyQt had deleted the printer, which print_view() held only as a local. Reproduced in the lab with the 0.2.2 code, same backtrace, no job. Keeping the printer alive fixed the crash and was measured, and Qt's path was still not a print path: a worksheet PDF tab printed as one blank page, a lesson went as 24.6 MB of 300 dpi pictures with no text, and a queue refusing jobs was reported as printed. Print is now: make a PDF (a worksheet already is one; a lesson page is printToPdf'd onto the printer's own paper; an inline PDF is downloaded and opened as a worksheet), show it in a preview with printer, pages, copies and colour, and hand it to CUPS with lp — Chrome's route in the same rooms. Nothing asynchronous holds a printer. Black and white uses the driver's ColorModel=Grayscale, because Elmer's generic PCL 6 driver ignores print-color-mode. An exception escaping any slot no longer aborts Wonder Ink: PyQt6's default is to kill the process, and it is now logged instead. tests/test-classroom-printing.sh: 50 checks. tools/lab/wonderink-print- check: 39 checks over 13 scenarios in the lab, against a queue built from Elmer's PPD, judged by what CUPS received. Not yet on paper. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
690ef95aff
commit
10433b850d
+553
-26
@@ -120,14 +120,24 @@ if WIDEVINE.exists():
|
||||
f"{flags} --widevine-path={WIDEVINE}".strip()
|
||||
)
|
||||
|
||||
from PyQt6.QtCore import QEvent, Qt, QUrl # noqa: E402
|
||||
from PyQt6.QtGui import QAction, QGuiApplication, QKeySequence # noqa: E402
|
||||
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
|
||||
from PyQt6.QtPrintSupport import ( # noqa: E402
|
||||
QAbstractPrintDialog,
|
||||
QPrintDialog,
|
||||
QPrinter,
|
||||
import classroom_printing # noqa: E402
|
||||
from PyQt6.QtCore import ( # noqa: E402
|
||||
QEvent,
|
||||
QMarginsF,
|
||||
Qt,
|
||||
QTimer,
|
||||
QUrl,
|
||||
pyqtSignal,
|
||||
)
|
||||
from PyQt6.QtGui import ( # noqa: E402
|
||||
QAction,
|
||||
QGuiApplication,
|
||||
QKeySequence,
|
||||
QPageLayout,
|
||||
QPageSize,
|
||||
QPixmap,
|
||||
)
|
||||
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
|
||||
from PyQt6.QtWebEngineCore import ( # noqa: E402
|
||||
QWebEngineDownloadRequest,
|
||||
QWebEnginePage,
|
||||
@@ -137,9 +147,20 @@ from PyQt6.QtWebEngineCore import ( # noqa: E402
|
||||
from PyQt6.QtWebEngineWidgets import QWebEngineView # noqa: E402
|
||||
from PyQt6.QtWidgets import ( # noqa: E402
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
# One running Wonder Ink per session. A second QtWebEngine process on the
|
||||
@@ -147,6 +168,14 @@ from PyQt6.QtWidgets import ( # noqa: E402
|
||||
# the panel gives a teacher a button that is easy to press twice.
|
||||
SOCKET = "ekklesia-wonderink"
|
||||
|
||||
# Chrome's default margins, near enough, so a lesson prints as it would
|
||||
# have from the ordinary browser.
|
||||
PRINT_MARGIN_MM = 10
|
||||
|
||||
# How long a lesson page has to become a PDF before the preview gives up
|
||||
# and says so. Measured: a long lesson page takes well under a second.
|
||||
PREPARE_TIMEOUT_MS = 20_000
|
||||
|
||||
# How much of a page title fits on a tab before it stops being a label
|
||||
# and starts being a paragraph.
|
||||
TAB_TITLE = 22
|
||||
@@ -280,6 +309,13 @@ class WonderInk(QMainWindow):
|
||||
# from the file, because a PDF has no document title to take one
|
||||
# from, and their downloads are not re-opened as further tabs.
|
||||
self._pdf_views = {}
|
||||
# Printing: see print_view. The preview on screen, if any; pages
|
||||
# whose inline PDF is being fetched so that it can be printed; and
|
||||
# what each tab was last answered with, which is how a PDF shown
|
||||
# inline is told from a page.
|
||||
self._preview = None
|
||||
self._print_when_downloaded = set()
|
||||
self._mime = {}
|
||||
|
||||
self.setWindowTitle("Wonder Ink")
|
||||
|
||||
@@ -313,6 +349,15 @@ class WonderInk(QMainWindow):
|
||||
# concludes the printer is broken. Bound to the page that asked
|
||||
# rather than to whichever tab is in front.
|
||||
view.page().printRequested.connect(lambda v=view: self.print_view(v))
|
||||
# A window.print() from inside a frame: Qt 6.8 reports it apart
|
||||
# from the main frame's, and it is still this tab being printed.
|
||||
if hasattr(view.page(), "printRequestedByFrame"):
|
||||
view.page().printRequestedByFrame.connect(
|
||||
lambda _frame, v=view: self.print_view(v)
|
||||
)
|
||||
view.page().loadingChanged.connect(
|
||||
lambda info, v=view: self._loading_changed(v, info)
|
||||
)
|
||||
|
||||
index = self._tabs.addTab(view, "…")
|
||||
|
||||
@@ -340,6 +385,7 @@ class WonderInk(QMainWindow):
|
||||
|
||||
self._tabs.removeTab(index)
|
||||
self._pdf_views.pop(view, None)
|
||||
self._mime.pop(view, None)
|
||||
view.page().deleteLater()
|
||||
view.deleteLater()
|
||||
self._show_tab_bar()
|
||||
@@ -361,7 +407,8 @@ class WonderInk(QMainWindow):
|
||||
# viewer leaves document.title empty, so left alone every PDF tab
|
||||
# would be called "Wonder Ink" and two of them would be
|
||||
# indistinguishable.
|
||||
title = self._pdf_views.get(view) or (title or "Wonder Ink")
|
||||
pdf = self._pdf_views.get(view)
|
||||
title = pdf.stem if pdf is not None else (title or "Wonder Ink")
|
||||
title = title.strip()
|
||||
short = title if len(title) <= TAB_TITLE else title[: TAB_TITLE - 1] + "…"
|
||||
self._tabs.setTabText(index, short)
|
||||
@@ -472,6 +519,10 @@ class WonderInk(QMainWindow):
|
||||
from_worksheet = download.page() in [
|
||||
view.page() for view in self._pdf_views
|
||||
]
|
||||
# Fetched by print_view, for a PDF the site showed inline.
|
||||
url = download.url().toString()
|
||||
print_after = url in self._print_when_downloaded
|
||||
self._print_when_downloaded.discard(url)
|
||||
|
||||
# stateChanged, not isFinishedChanged.
|
||||
#
|
||||
@@ -507,6 +558,8 @@ class WonderInk(QMainWindow):
|
||||
path = DOWNLOADS / name
|
||||
|
||||
if not from_worksheet and self.open_worksheet(path, mime):
|
||||
if print_after and self.current() in self._pdf_views:
|
||||
QTimer.singleShot(0, self.print_current)
|
||||
return
|
||||
|
||||
notify("Saved", f"{name} is in {DOWNLOADS}")
|
||||
@@ -550,40 +603,128 @@ class WonderInk(QMainWindow):
|
||||
self._openable.add(resolved)
|
||||
|
||||
view = self.open_tab()
|
||||
self._pdf_views[view] = path.stem
|
||||
# The file itself, not only its name: a worksheet is printed from
|
||||
# the PDF on disk, because the engine cannot print its own viewer.
|
||||
self._pdf_views[view] = resolved
|
||||
self._retitle(view, path.stem)
|
||||
view.load(QUrl.fromLocalFile(str(resolved)))
|
||||
return view
|
||||
|
||||
# ---- printing -------------------------------------------------------
|
||||
|
||||
# WHAT PRINT DOES, AND WHY IT IS NOT QT'S PRINT DIALOG ANY MORE.
|
||||
#
|
||||
# Little Waders, 0.2.2: Wonder Ink disappeared every time a teacher
|
||||
# pressed Print in the dialog, SIGSEGV in QPrinter::resolution().
|
||||
# QWebEngineView.print() is asynchronous: it keeps the raw QPrinter
|
||||
# pointer and returns at once, and when Chromium has rendered the page
|
||||
# Qt's didPrintPage() reads printer->resolution() and hands the
|
||||
# printer to a worker thread. The printer was a local variable here,
|
||||
# so PyQt had deleted it by then. Reproduced in the lab with the 0.2.2
|
||||
# code — the same backtrace, frame for frame — and no job reached CUPS.
|
||||
#
|
||||
# Keeping the printer alive fixed the crash, and was measured, and was
|
||||
# still the wrong answer: that same path sent a worksheet tab as one
|
||||
# blank page, sent a lesson as 24 MB of pictures, and called a job
|
||||
# the printer had refused a success. See lib/classroom_printing.py.
|
||||
#
|
||||
# So Print now makes a PDF — a worksheet already is one; a lesson page
|
||||
# is rendered by the engine onto the printer's own paper — shows the
|
||||
# teacher that PDF, and hands it to CUPS with lp, which is how Chrome
|
||||
# prints in the same room. Nothing asynchronous holds a printer, so
|
||||
# closing a tab or Wonder Ink afterwards has nothing left to break.
|
||||
|
||||
def print_current(self):
|
||||
self.print_view(self.current())
|
||||
|
||||
def print_view(self, view):
|
||||
if view is None:
|
||||
if view is None or self._preview is not None:
|
||||
return
|
||||
|
||||
printer = QPrinter(QPrinter.PrinterMode.HighResolution)
|
||||
dialog = QPrintDialog(printer, self)
|
||||
title = self._tabs.tabToolTip(self._tabs.indexOf(view)) or "Wonder Ink"
|
||||
pdf = self._pdf_views.get(view)
|
||||
|
||||
# No "Print to File".
|
||||
#
|
||||
# Qt offers it in every print dialog, and choosing it enables an
|
||||
# output path with a "..." button beside it — which is a file
|
||||
# chooser, in a room whose whole design is that there is no file
|
||||
# chooser. Nothing in a classroom wants a worksheet written to a
|
||||
# PDF nobody can then open; the room has a printer, and that is
|
||||
# what this dialog is for.
|
||||
dialog.setOption(
|
||||
QAbstractPrintDialog.PrintDialogOption.PrintToFile, False
|
||||
if pdf is None and self._shows_pdf(view):
|
||||
# A PDF the site showed inline rather than sending as a
|
||||
# download. Measured: the engine cannot render the PDF viewer
|
||||
# for printing at all — printToPdf never answers — so it is
|
||||
# fetched as the file it is, opened as a worksheet like any
|
||||
# other, and printed from there.
|
||||
self._print_when_downloaded.add(view.url().toString())
|
||||
view.page().download(view.url())
|
||||
return
|
||||
|
||||
dialog = PrintPreview(self, title)
|
||||
self._preview = dialog
|
||||
|
||||
if pdf is not None:
|
||||
dialog.show_document(pdf)
|
||||
else:
|
||||
dialog.paper_wanted.connect(
|
||||
lambda paper, v=view, d=dialog: self._render(v, d, paper)
|
||||
)
|
||||
self._render(view, dialog, dialog.paper())
|
||||
|
||||
# A page may close its own tab while the teacher is looking at the
|
||||
# preview. The preview says so rather than printing nothing.
|
||||
watch = view.destroyed.connect(
|
||||
lambda *_: dialog.fail("The page was closed.")
|
||||
)
|
||||
|
||||
if dialog.exec() != QPrintDialog.DialogCode.Accepted:
|
||||
try:
|
||||
dialog.exec()
|
||||
finally:
|
||||
try:
|
||||
view.destroyed.disconnect(watch)
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
dialog.finish()
|
||||
dialog.deleteLater()
|
||||
self._preview = None
|
||||
|
||||
def _render(self, view, dialog, paper):
|
||||
"""Ask the engine for this page as a PDF on PAPER, for DIALOG."""
|
||||
attempt = dialog.preparing()
|
||||
size = QPageSize(getattr(QPageSize.PageSizeId, paper))
|
||||
layout = QPageLayout(
|
||||
size, QPageLayout.Orientation.Portrait,
|
||||
QMarginsF(*(PRINT_MARGIN_MM,) * 4), QPageLayout.Unit.Millimeter,
|
||||
)
|
||||
|
||||
# The callback may arrive after the dialog has gone, or never:
|
||||
# attempt is a plain Python object, so asking it is always safe,
|
||||
# and the dialog's own timer gives up on an answer that does not
|
||||
# come.
|
||||
def rendered(data):
|
||||
if attempt.current():
|
||||
dialog.show_rendered(bytes(data))
|
||||
|
||||
view.page().printToPdf(rendered, layout)
|
||||
|
||||
def _shows_pdf(self, view):
|
||||
"""Is this tab the engine's PDF viewer showing a remote PDF?"""
|
||||
if self._mime.get(view) == "application/pdf":
|
||||
return True
|
||||
|
||||
return view.url().path().lower().endswith(".pdf")
|
||||
|
||||
def _loading_changed(self, view, info):
|
||||
if info.status() != info.LoadStatus.LoadSucceededStatus:
|
||||
return
|
||||
|
||||
# Qt's own dialog talks to CUPS, so a lesson prints to the room's
|
||||
# queue the same way everything else in the classroom does.
|
||||
view.print(printer)
|
||||
# responseHeaders() is Qt 6.6 and later; Debian 13 has 6.8. PyQt
|
||||
# gives it as a dict of lists — it is a multi-map in C++.
|
||||
headers = getattr(info, "responseHeaders", dict)()
|
||||
mime = ""
|
||||
|
||||
for key, values in headers.items():
|
||||
if key.data().decode("latin-1").lower() != "content-type":
|
||||
continue
|
||||
|
||||
for value in values if isinstance(values, list) else [values]:
|
||||
mime = value.data().decode("latin-1").split(";")[0].strip().lower()
|
||||
|
||||
self._mime[view] = mime
|
||||
|
||||
# ---- full screen ----------------------------------------------------
|
||||
|
||||
@@ -633,6 +774,375 @@ class WonderInk(QMainWindow):
|
||||
self._show_tab_bar()
|
||||
|
||||
|
||||
class _Attempt:
|
||||
"""One request for a PDF. Stale once another is made or the dialog ends.
|
||||
|
||||
A plain Python object on purpose: the engine's callback can arrive
|
||||
after the dialog has been deleted, and asking this is always safe
|
||||
where asking a deleted Qt object is not.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.live = True
|
||||
|
||||
def current(self):
|
||||
return self.live
|
||||
|
||||
|
||||
class PageView(QWidget):
|
||||
"""One page of the document, whole, as it will come out of the printer."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._pixmap = None
|
||||
self._message = ""
|
||||
self.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
|
||||
)
|
||||
self.setMinimumSize(320, 400)
|
||||
|
||||
def show_pixmap(self, pixmap):
|
||||
self._pixmap, self._message = pixmap, ""
|
||||
self.update()
|
||||
|
||||
def show_message(self, message):
|
||||
self._pixmap, self._message = None, message
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
from PyQt6.QtGui import QColor, QPainter
|
||||
|
||||
painter = QPainter(self)
|
||||
painter.fillRect(self.rect(), self.palette().window().color().darker(115))
|
||||
|
||||
if self._pixmap is None:
|
||||
painter.setPen(self.palette().windowText().color())
|
||||
painter.drawText(
|
||||
self.rect(), Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap,
|
||||
self._message,
|
||||
)
|
||||
return
|
||||
|
||||
# The page's own shape, as large as fits. Never stretched: a
|
||||
# preview that changes the proportions is not showing the print.
|
||||
area = self.rect().adjusted(16, 16, -16, -16)
|
||||
scaled = self._pixmap.size().scaled(
|
||||
area.size(), Qt.AspectRatioMode.KeepAspectRatio
|
||||
)
|
||||
x = area.x() + (area.width() - scaled.width()) // 2
|
||||
y = area.y() + (area.height() - scaled.height()) // 2
|
||||
painter.fillRect(x + 3, y + 3, scaled.width(), scaled.height(), QColor(0, 0, 0, 60))
|
||||
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
||||
painter.drawPixmap(x, y, scaled.width(), scaled.height(), self._pixmap)
|
||||
|
||||
|
||||
class PrintPreview(QDialog):
|
||||
"""The document as it will print, and the few choices a teacher has.
|
||||
|
||||
Printer, which pages, how many copies, and colour or black and white
|
||||
where the printer's driver can do both. No paper tray, no scaling, no
|
||||
"print to file" — there is no file chooser anywhere in this room, and
|
||||
a print dialog is the usual way one gets in.
|
||||
"""
|
||||
|
||||
# A lesson page is laid out for a paper size; choosing a printer with
|
||||
# different paper asks for it again.
|
||||
paper_wanted = pyqtSignal(str)
|
||||
|
||||
# Wide enough to stay sharp on a television at twice scale.
|
||||
RENDER_WIDTH = 1200
|
||||
|
||||
def __init__(self, window, title):
|
||||
super().__init__(window)
|
||||
self.setWindowTitle(f"Print — {title}")
|
||||
self.setModal(True)
|
||||
|
||||
self._title = title
|
||||
self._pdf = None
|
||||
self._scratch = None
|
||||
self._pages = 0
|
||||
self._page = 1
|
||||
self._images = {}
|
||||
self._attempt = None
|
||||
self._paper = None
|
||||
self._options = {}
|
||||
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(
|
||||
lambda: self.fail("The page took too long to prepare. Close this and try again.")
|
||||
)
|
||||
|
||||
# -- the page ------------------------------------------------------
|
||||
self._view = PageView(self)
|
||||
self._previous = QPushButton("‹", self)
|
||||
self._next = QPushButton("›", self)
|
||||
self._where = QLabel(self)
|
||||
self._where.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._previous.clicked.connect(lambda: self._turn(-1))
|
||||
self._next.clicked.connect(lambda: self._turn(1))
|
||||
|
||||
pager = QHBoxLayout()
|
||||
pager.addStretch(1)
|
||||
pager.addWidget(self._previous)
|
||||
pager.addWidget(self._where)
|
||||
pager.addWidget(self._next)
|
||||
pager.addStretch(1)
|
||||
|
||||
left = QVBoxLayout()
|
||||
left.addWidget(self._view, 1)
|
||||
left.addLayout(pager)
|
||||
|
||||
# -- the choices ---------------------------------------------------
|
||||
self._printer = QComboBox(self)
|
||||
self._which = QComboBox(self)
|
||||
self._which.addItems(["All pages", "Only some pages"])
|
||||
self._range = QLineEdit(self)
|
||||
self._range.setPlaceholderText("for example 1-3, 5")
|
||||
self._range.setVisible(False)
|
||||
self._which.currentIndexChanged.connect(self._which_changed)
|
||||
self._copies = QSpinBox(self)
|
||||
self._copies.setRange(1, classroom_printing.MAX_COPIES)
|
||||
self._colour = QComboBox(self)
|
||||
self._colour.addItems(["Colour", "Black and white"])
|
||||
self._colour_label = QLabel("Colour", self)
|
||||
|
||||
form = QFormLayout()
|
||||
form.addRow("Printer", self._printer)
|
||||
form.addRow("Pages", self._which)
|
||||
form.addRow("", self._range)
|
||||
form.addRow("Copies", self._copies)
|
||||
form.addRow(self._colour_label, self._colour)
|
||||
|
||||
self._status = QLabel(self)
|
||||
self._status.setWordWrap(True)
|
||||
|
||||
self._cancel = QPushButton("Cancel", self)
|
||||
self._print = QPushButton("Print", self)
|
||||
self._print.setDefault(True)
|
||||
self._cancel.clicked.connect(self.reject)
|
||||
self._print.clicked.connect(self._submit)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch(1)
|
||||
buttons.addWidget(self._cancel)
|
||||
buttons.addWidget(self._print)
|
||||
|
||||
right = QVBoxLayout()
|
||||
right.addLayout(form)
|
||||
right.addStretch(1)
|
||||
right.addWidget(self._status)
|
||||
right.addLayout(buttons)
|
||||
|
||||
side = QWidget(self)
|
||||
side.setLayout(right)
|
||||
side.setMinimumWidth(300)
|
||||
side.setMaximumWidth(420)
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.addLayout(left, 1)
|
||||
layout.addWidget(side)
|
||||
|
||||
names, default = classroom_printing.printers()
|
||||
self._printer.addItems(names)
|
||||
|
||||
if default:
|
||||
self._printer.setCurrentText(default)
|
||||
|
||||
self._printer.currentTextChanged.connect(self._printer_changed)
|
||||
self._printer_changed(self._printer.currentText(), initial=True)
|
||||
|
||||
screen = window.screen().availableGeometry() if window.screen() else None
|
||||
|
||||
if screen is not None:
|
||||
self.resize(int(screen.width() * 0.8), int(screen.height() * 0.85))
|
||||
|
||||
self._show_page()
|
||||
|
||||
if not names:
|
||||
self.fail("No printer is set up on this computer. Ask for help.")
|
||||
|
||||
# -- what the window tells it -----------------------------------------
|
||||
|
||||
def paper(self):
|
||||
return self._paper or "Letter"
|
||||
|
||||
def preparing(self):
|
||||
"""A new PDF is on its way; anything older is stale."""
|
||||
if self._attempt is not None:
|
||||
self._attempt.live = False
|
||||
|
||||
self._attempt = _Attempt()
|
||||
self._clear()
|
||||
self._say("Preparing the preview…")
|
||||
self._view.show_message("Preparing the preview…")
|
||||
self._timer.start(PREPARE_TIMEOUT_MS)
|
||||
return self._attempt
|
||||
|
||||
def show_rendered(self, data):
|
||||
"""The engine's PDF of a lesson page, as bytes."""
|
||||
self._timer.stop()
|
||||
|
||||
if self._attempt is not None:
|
||||
self._attempt.live = False
|
||||
|
||||
if not data.startswith(PDF_MAGIC):
|
||||
self.fail("This page could not be prepared for printing.")
|
||||
return
|
||||
|
||||
import tempfile
|
||||
|
||||
if self._scratch is None:
|
||||
# The teacher's own runtime directory: private to them, in
|
||||
# memory, and gone at logout even if this is never cleaned.
|
||||
self._scratch = tempfile.mkdtemp(
|
||||
prefix="wonderink-print-", dir=os.environ.get("XDG_RUNTIME_DIR")
|
||||
)
|
||||
|
||||
path = Path(self._scratch) / f"page-{id(data)}.pdf"
|
||||
path.write_bytes(data)
|
||||
self.show_document(path)
|
||||
|
||||
def show_document(self, pdf):
|
||||
self._pdf = Path(pdf)
|
||||
self._pages = classroom_printing.page_count(self._pdf)
|
||||
|
||||
if self._pages < 1:
|
||||
self.fail("This document could not be read.")
|
||||
return
|
||||
|
||||
self._page = 1
|
||||
self._images = {}
|
||||
self._say("")
|
||||
self._print.setEnabled(bool(self._printer.count()))
|
||||
self._show_page()
|
||||
|
||||
def fail(self, message):
|
||||
self._timer.stop()
|
||||
|
||||
if self._attempt is not None:
|
||||
self._attempt.live = False
|
||||
|
||||
self._clear()
|
||||
self._view.show_message(message)
|
||||
self._say(message, error=True)
|
||||
|
||||
def finish(self):
|
||||
"""Everything this preview made, gone."""
|
||||
self._timer.stop()
|
||||
|
||||
if self._attempt is not None:
|
||||
self._attempt.live = False
|
||||
|
||||
if self._scratch is not None:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(self._scratch, ignore_errors=True)
|
||||
self._scratch = None
|
||||
|
||||
# -- its own behaviour ------------------------------------------------
|
||||
|
||||
def _clear(self):
|
||||
self._pdf = None
|
||||
self._pages = 0
|
||||
self._images = {}
|
||||
self._print.setEnabled(False)
|
||||
self._show_page()
|
||||
|
||||
def _say(self, message, error=False):
|
||||
self._status.setText(message)
|
||||
self._status.setStyleSheet("color: #d9534f;" if error else "")
|
||||
|
||||
def _which_changed(self, index):
|
||||
self._range.setVisible(index == 1)
|
||||
|
||||
if index == 1:
|
||||
self._range.setFocus()
|
||||
|
||||
def _printer_changed(self, name, initial=False):
|
||||
if not name:
|
||||
return
|
||||
|
||||
if name not in self._options:
|
||||
self._options[name] = classroom_printing.options(name)
|
||||
|
||||
found = self._options[name]
|
||||
self._colour.setVisible(found["gray"] is not None)
|
||||
self._colour_label.setVisible(found["gray"] is not None)
|
||||
|
||||
if found["paper"] != self._paper:
|
||||
self._paper = found["paper"]
|
||||
|
||||
if not initial:
|
||||
self.paper_wanted.emit(self._paper)
|
||||
|
||||
def _turn(self, step):
|
||||
self._page = max(1, min(self._pages, self._page + step))
|
||||
self._show_page()
|
||||
|
||||
def _show_page(self):
|
||||
showing = self._pdf is not None and self._pages > 0
|
||||
self._previous.setEnabled(showing and self._page > 1)
|
||||
self._next.setEnabled(showing and self._page < self._pages)
|
||||
self._where.setText(f"Page {self._page} of {self._pages}" if showing else "")
|
||||
|
||||
if not showing:
|
||||
return
|
||||
|
||||
if self._page not in self._images:
|
||||
data = classroom_printing.render_page(self._pdf, self._page, self.RENDER_WIDTH)
|
||||
pixmap = QPixmap()
|
||||
|
||||
if data is None or not pixmap.loadFromData(data):
|
||||
self._view.show_message(f"Page {self._page} could not be shown.")
|
||||
return
|
||||
|
||||
self._images[self._page] = pixmap
|
||||
|
||||
self._view.show_pixmap(self._images[self._page])
|
||||
|
||||
def _submit(self):
|
||||
if self._pdf is None:
|
||||
return
|
||||
|
||||
try:
|
||||
pages = classroom_printing.parse_pages(
|
||||
self._range.text() if self._which.currentIndex() == 1 else "",
|
||||
self._pages,
|
||||
)
|
||||
except ValueError as error:
|
||||
self._say(str(error), error=True)
|
||||
self._range.setFocus()
|
||||
return
|
||||
|
||||
printer = self._printer.currentText()
|
||||
gray = None
|
||||
|
||||
if self._colour.isVisible() and self._colour.currentIndex() == 1:
|
||||
gray = self._options[printer]["gray"]
|
||||
|
||||
self._say(f"Sending to {printer}…")
|
||||
self._print.setEnabled(False)
|
||||
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
||||
|
||||
try:
|
||||
ok, answer = classroom_printing.submit(
|
||||
self._pdf, printer, self._copies.value(), pages, gray, self._title
|
||||
)
|
||||
finally:
|
||||
QApplication.restoreOverrideCursor()
|
||||
|
||||
if not ok:
|
||||
say(f"print to {printer} refused: {answer}")
|
||||
self._say(f"{printer} did not take the job: {answer}", error=True)
|
||||
self._print.setEnabled(True)
|
||||
return
|
||||
|
||||
say(f"printed {self._title!r} to {printer} as {answer}")
|
||||
notify("Sent to the printer", f"{self._title} — {printer}")
|
||||
self.accept()
|
||||
|
||||
|
||||
def notify(summary, body):
|
||||
import subprocess
|
||||
|
||||
@@ -676,6 +1186,21 @@ def raise_running_instance():
|
||||
return True
|
||||
|
||||
|
||||
def keep_going(kind, value, trace):
|
||||
"""An error in Wonder Ink's own code is logged, and the lesson stays.
|
||||
|
||||
PyQt6's answer to an exception escaping any slot — a download
|
||||
finishing, a title changing, a button in the print preview — is to
|
||||
abort the whole process, measured in the lab: one mistake in one
|
||||
handler and the window a teacher is presenting from vanishes. The
|
||||
journal is the right place for that mistake, not the classroom.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
say("unexpected error, carrying on:")
|
||||
traceback.print_exception(kind, value, trace, file=sys.stderr)
|
||||
|
||||
|
||||
def main():
|
||||
config = read_device_config()
|
||||
|
||||
@@ -693,6 +1218,8 @@ def main():
|
||||
if raise_running_instance():
|
||||
return 0
|
||||
|
||||
sys.excepthook = keep_going
|
||||
|
||||
QGuiApplication.setDesktopFileName("ekklesia-wonderink")
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Wonder Ink")
|
||||
|
||||
Reference in New Issue
Block a user